commit b16403ea719d5e3917f9febfa766790be2ccd9ea Author: wehub-resource-sync Date: Mon Jul 13 12:47:58 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..e325fcc --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,9 @@ +# Changesets + +To add changeset run: + +```bash +npx changeset +``` + +in the root of the project. This will create a new changeset in the `.changeset` folder. \ No newline at end of file diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..3569201 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "ignore": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "privatePackages": { + "version": true, + "tag": true + } +} diff --git a/.changeset/js-connectrpc-stable.md b/.changeset/js-connectrpc-stable.md new file mode 100644 index 0000000..7a0f08e --- /dev/null +++ b/.changeset/js-connectrpc-stable.md @@ -0,0 +1,5 @@ +--- +"e2b": patch +--- + +Move the Connect/Protobuf runtime dependencies off the `2.0.0-rc.3` pre-release pin to the stable line: `@connectrpc/connect` and `@connectrpc/connect-web` upgrade to `^2.1.2`, and `@bufbuild/protobuf` upgrades from `^2.6.2` to `^2.12.1`. No public API changes — the sandbox filesystem and command RPCs continue to use the same Connect transport configuration. diff --git a/.changeset/set-integration-once.md b/.changeset/set-integration-once.md new file mode 100644 index 0000000..32ab943 --- /dev/null +++ b/.changeset/set-integration-once.md @@ -0,0 +1,6 @@ +--- +"e2b": minor +"@e2b/python-sdk": minor +--- + +Replace the per-call `integration` connection option with a set-once, process-wide `ConnectionConfig.setIntegration()` (JS) / `ConnectionConfig.set_integration()` (Python). Integrations wrapping the SDK call it once at startup and every request is attributed via the `User-Agent` header — no more threading the option through individual SDK calls. The method is internal and hidden from docs. The `integration` option on `ConnectionConfigOpts` (JS) and the `integration` keyword argument on `ConnectionConfig` (Python) are removed; `ConnectionConfigOpts` remains as a deprecated alias of `ConnectionOpts`. In both SDKs, an explicitly provided `User-Agent` header now always takes precedence over the SDK-built one, while SDK-built values are recomputed whenever a config is rebuilt so they always reflect the current integration. diff --git a/.changeset/switch-to-tsdown.md b/.changeset/switch-to-tsdown.md new file mode 100644 index 0000000..072e8c4 --- /dev/null +++ b/.changeset/switch-to-tsdown.md @@ -0,0 +1,8 @@ +--- +"@e2b/cli": patch +"e2b": patch +--- + +Switch the build tooling from `tsup` to `tsdown`. The published artifacts are unchanged: the SDK still ships `dist/index.js` (CJS), `dist/index.mjs` (ESM) and `dist/index.d.ts`/`dist/index.d.mts`, and the CLI still ships an executable `dist/index.js` with its `dist/templates`. + +`engines.node` for both packages is set to `>=20.18.1 <21 || >=22` (Node 20.18.1+, or 22 and above — keeping the minimum required by `undici` while excluding the end-of-life Node 21 line). diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c29d2f8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*.py] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f61d388 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +packages/python-sdk/e2b/api/client/** linguist-generated=true +packages/python-sdk/e2b/envd/filesystem/** linguist-generated=true +packages/python-sdk/e2b/envd/process/** linguist-generated=true +**/*.gen.ts linguist-generated=true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..cff7156 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,75 @@ +name: Bug report +description: Create a report to help us improve +title: "[Bug]: " +labels: ["bug"] +body: + - type: input + id: sandbox_id + attributes: + label: Sandbox ID or Build ID + + - type: textarea + id: environment + attributes: + label: Environment + description: Environment details (used libraries version, OS, etc..) + placeholder: e2b==1.2.3, macOS 14 + validations: + required: true + + - type: input + id: timestamp + attributes: + label: Timestamp of the issue + description: When did the error occur? (include timezone please) + placeholder: e.g. 2026-01-12 14:32 UTC + validations: + required: true + + - type: dropdown + id: frequency + attributes: + label: Frequency + options: + - One-time occurrence + - Happens occasionally + - Happens every time + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected behavior + description: What did you expect to happen? + placeholder: Describe the expected behavior... + validations: + required: true + + - type: textarea + id: actual-behavior + attributes: + label: Actual behavior + description: What actually happened? + placeholder: Describe what actually occurred... + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Issue reproduction + description: Steps to reproduce the issue + placeholder: | + 1. Go to `...` + 2. Run `...` + 3. See error + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Logs, screenshots, or any other relevant information + placeholder: Add any additional context here... \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..9e42869 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: E2B Docs + url: https://e2b.dev/docs + about: Read the E2B documentation. + - name: E2B Discord + url: https://discord.gg/dSBY3ms2Qr + about: Ask questions and get help from the E2B community. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..fa936ff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: 'feature' +assignees: '' +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/actions/build-cli/action.yml b/.github/actions/build-cli/action.yml new file mode 100644 index 0000000..35f6ecc --- /dev/null +++ b/.github/actions/build-cli/action.yml @@ -0,0 +1,41 @@ +name: 'Build and install E2B CLI' +description: >- + Builds the e2b CLI from source in this repo and installs it globally so the + `e2b` command is available on PATH. Assumes the repository has already been + checked out. + +runs: + using: 'composite' + steps: + - name: Parse .tool-versions + uses: wistia/parse-tool-versions@32f568a4ffd4bfa7720ebf93f171597d1ebc979a # v2.1.1 + with: + filename: '.tool-versions' + uppercase: 'true' + prefix: 'tool_version_' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: '${{ env.TOOL_VERSION_PNPM }}' + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '${{ env.TOOL_VERSION_NODEJS }}' + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile + + - name: Build the CLI + shell: bash + run: pnpm build + working-directory: ./packages/cli + + - name: Install the CLI globally + shell: bash + run: npm install -g . + working-directory: ./packages/cli diff --git a/.github/scripts/is_release.sh b/.github/scripts/is_release.sh new file mode 100755 index 0000000..436d0f0 --- /dev/null +++ b/.github/scripts/is_release.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +# This script checks if the current commit contains changesets. + +set -eu + +CHANGES=$(node -e "require('@changesets/read').default(process.cwd()).then(result => console.log(!!result.length))") + +echo "${CHANGES}" diff --git a/.github/scripts/is_release_for_package.sh b/.github/scripts/is_release_for_package.sh new file mode 100755 index 0000000..c8965ef --- /dev/null +++ b/.github/scripts/is_release_for_package.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +# This script checks if the specified package has changesets in the current commit. + +set -eu + +if [ $# -lt 1 ]; then + echo "Error: Package name is required as the first argument." >&2 + exit 1 +fi + +PACKAGE_NAME=$1 +PACKAGE_CHANGES=$(node -e "require('@changesets/read').default(process.cwd()).then(result => console.log(result.flatMap(changeset => changeset.releases.flatMap(release => release.name)).includes('${PACKAGE_NAME}')))") + +echo "${PACKAGE_CHANGES}" diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml new file mode 100644 index 0000000..bdea52e --- /dev/null +++ b/.github/workflows/cli_tests.yml @@ -0,0 +1,72 @@ +name: Test CLI + +on: + workflow_call: + inputs: + E2B_DOMAIN: + required: false + type: string + default: '' + secrets: + E2B_API_KEY: + required: true + +permissions: + contents: read + +jobs: + test: + defaults: + run: + working-directory: ./packages/cli + shell: bash + name: CLI - Build (${{ matrix.os }}) + strategy: + matrix: + os: [ubuntu-22.04, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Parse .tool-versions + uses: wistia/parse-tool-versions@v2.1.1 + with: + filename: '.tool-versions' + uppercase: 'true' + prefix: 'tool_version_' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: '${{ env.TOOL_VERSION_PNPM }}' + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '${{ env.TOOL_VERSION_NODEJS }}' + registry-url: 'https://registry.npmjs.org' + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Configure pnpm + run: | + pnpm config set auto-install-peers true + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build the SDK (pre-requisite for the tests) + run: pnpm build + working-directory: ./packages/js-sdk + + - name: Build the CLI + run: pnpm build + working-directory: ./packages/cli + + - name: Run tests + run: pnpm test + working-directory: ./packages/cli + env: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }} diff --git a/.github/workflows/generated_files.yml b/.github/workflows/generated_files.yml new file mode 100644 index 0000000..39d1a85 --- /dev/null +++ b/.github/workflows/generated_files.yml @@ -0,0 +1,99 @@ +name: Generated files + +on: + pull_request: + +permissions: + contents: read + +jobs: + changes: + name: Detect changes + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + generated: ${{ steps.filter.outputs.generated }} + steps: + - name: Filter changed paths + uses: dorny/paths-filter@v3 + id: filter + with: + # `**/!(*.md)` excludes Markdown so docs-only changes don't trigger codegen checks. + filters: | + generated: + - 'spec/**/!(*.md)' + - 'codegen.Dockerfile' + - 'Makefile' + - 'packages/**/!(*.md)' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - '.tool-versions' + - '.github/workflows/generated_files.yml' + + check-generated: + name: Generated files + needs: changes + if: ${{ needs.changes.outputs.generated == 'true' }} + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Parse .tool-versions + uses: wistia/parse-tool-versions@v2.1.1 + with: + filename: '.tool-versions' + uppercase: 'true' + prefix: 'tool_version_' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + id: pnpm-install + with: + version: '${{ env.TOOL_VERSION_PNPM }}' + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '${{ env.TOOL_VERSION_NODEJS }}' + registry-url: 'https://registry.npmjs.org' + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Configure pnpm + run: | + pnpm config set auto-install-peers true + pnpm config set exclude-links-from-lockfile true + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build codegen image with caching + uses: docker/build-push-action@v6 + with: + context: . + file: codegen.Dockerfile + tags: codegen-env:latest + load: true # makes the image available for `docker run` + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run codegen + run: CODEGEN_IMAGE=codegen-env:latest make codegen + + - name: Check for uncommitted changes + run: | + if [[ -n $(git status --porcelain) ]]; then + echo "❌ Generated files are not up to date:" + git status --short + git diff + exit 1 + else + echo "✅ No changes detected." + fi diff --git a/.github/workflows/js_sdk_tests.yml b/.github/workflows/js_sdk_tests.yml new file mode 100644 index 0000000..c9f9059 --- /dev/null +++ b/.github/workflows/js_sdk_tests.yml @@ -0,0 +1,100 @@ +name: Test JS SDK + +on: + workflow_call: + inputs: + E2B_DOMAIN: + required: false + type: string + default: '' + secrets: + E2B_API_KEY: + required: true + +permissions: + contents: read + +jobs: + test: + defaults: + run: + working-directory: ./packages/js-sdk + shell: bash + name: JS SDK - Build and test (${{ matrix.os }}) + strategy: + matrix: + os: [ubuntu-22.04, windows-latest] + fail-fast: false + runs-on: ${{ matrix.os }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Parse .tool-versions + uses: wistia/parse-tool-versions@v2.1.1 + with: + filename: '.tool-versions' + uppercase: 'true' + prefix: 'tool_version_' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + id: pnpm-install + with: + version: '${{ env.TOOL_VERSION_PNPM }}' + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '${{ env.TOOL_VERSION_NODEJS }}' + registry-url: 'https://registry.npmjs.org' + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Configure pnpm + run: | + pnpm config set auto-install-peers true + pnpm config set exclude-links-from-lockfile true + + - name: Install dependencies + run: | + pnpm install --frozen-lockfile + + - name: Get Playwright version + id: playwright-version + run: echo "version=$(node -p "require('playwright/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ${{ matrix.os == 'windows-latest' && '~/AppData/Local/ms-playwright' || '~/.cache/ms-playwright' }} + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + + - name: Test build + run: pnpm build + + - name: Run Node tests + run: pnpm test + env: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }} + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + + - name: Run Bun tests + run: pnpm test:bun + env: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }} + + - name: Install Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v${{ env.TOOL_VERSION_DENO }} + + - name: Run Deno tests + run: pnpm test:deno + env: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..06e8c1e --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,101 @@ +name: Lint +permissions: + contents: read + +on: + pull_request: + +jobs: + changes: + name: Detect changes + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - name: Filter changed paths + uses: dorny/paths-filter@v3 + id: filter + with: + # `**/!(*.md)` excludes Markdown so docs-only changes don't trigger linting. + filters: | + code: + - 'packages/**/!(*.md)' + - 'spec/**/!(*.md)' + - '.oxlintrc.json' + - '.prettierrc' + - '.prettierignore' + - '.editorconfig' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - '.tool-versions' + - '.github/workflows/lint.yml' + + lint: + name: Lint + needs: changes + if: ${{ needs.changes.outputs.code == 'true' }} + runs-on: ubuntu-latest + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Parse .tool-versions + uses: wistia/parse-tool-versions@v2.1.1 + with: + filename: '.tool-versions' + uppercase: 'true' + prefix: 'tool_version_' + + - uses: pnpm/action-setup@v4 + with: + version: '${{ env.TOOL_VERSION_PNPM }}' + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '${{ env.TOOL_VERSION_NODEJS }}' + cache: pnpm + + - name: Configure pnpm + run: | + pnpm config set auto-install-peers true + pnpm config set exclude-links-from-lockfile true + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: '${{ env.TOOL_VERSION_UV }}' + python-version: '${{ env.TOOL_VERSION_PYTHON }}' + enable-cache: true + + - name: Install Python dependencies + working-directory: packages/python-sdk + run: | + uv sync --locked + + - name: Run linting + run: | + pnpm run lint + + - name: Run formatting + run: | + pnpm run format + + - name: Check for uncommitted changes + run: | + if [[ -n $(git status --porcelain) ]]; then + echo "❌ Files are not formatted properly:" + git status --short + git diff + exit 1 + else + echo "✅ No changes detected." + fi diff --git a/.github/workflows/pkg_artifacts.yml b/.github/workflows/pkg_artifacts.yml new file mode 100644 index 0000000..bd20116 --- /dev/null +++ b/.github/workflows/pkg_artifacts.yml @@ -0,0 +1,142 @@ +name: Package Artifacts + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + build: + name: Build Packages + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Parse .tool-versions + uses: wistia/parse-tool-versions@v2.1.1 + with: + filename: '.tool-versions' + uppercase: 'true' + prefix: 'tool_version_' + + - uses: pnpm/action-setup@v4 + with: + version: '${{ env.TOOL_VERSION_PNPM }}' + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '${{ env.TOOL_VERSION_NODEJS }}' + cache: pnpm + + - name: Configure pnpm + run: | + pnpm config set auto-install-peers true + pnpm config set exclude-links-from-lockfile true + + - name: Sanitize branch name + env: + BRANCH: ${{ github.head_ref }} + run: | + echo "BRANCH_ID=$(echo "$BRANCH" | sed 's/[^0-9A-Za-z-]/-/g')" >> "$GITHUB_ENV" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build JS SDK + working-directory: packages/js-sdk + run: pnpm run build + + - name: Pack JS SDK + working-directory: packages/js-sdk + run: | + npm version prerelease --preid=${{ env.BRANCH_ID }} --no-git-tag-version + npm pack + + - name: Upload JS SDK artifact + uses: actions/upload-artifact@v4 + with: + name: e2b-js-sdk + path: packages/js-sdk/*.tgz + + - name: Build CLI + working-directory: packages/cli + run: pnpm run build + + - name: Pack CLI + working-directory: packages/cli + run: | + npm version prerelease --preid=${{ env.BRANCH_ID }} --no-git-tag-version + npm pack + + - name: Upload CLI artifact + uses: actions/upload-artifact@v4 + with: + name: e2b-cli + path: packages/cli/*.tgz + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: '${{ env.TOOL_VERSION_UV }}' + python-version: '${{ env.TOOL_VERSION_PYTHON }}' + enable-cache: true + + - name: Build Python SDK + working-directory: packages/python-sdk + run: | + BASE_VERSION=$(uv version --short) + uv version "${BASE_VERSION}+${BRANCH_ID}" + uv build + + - name: Upload Python SDK artifact + uses: actions/upload-artifact@v4 + with: + name: e2b-python-sdk + path: packages/python-sdk/dist/* + + - name: Comment on PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + JS_VERSION=$(node -p "require('./packages/js-sdk/package.json').version") + CLI_VERSION=$(node -p "require('./packages/cli/package.json').version") + JS_TGZ=$(ls packages/js-sdk/*.tgz | xargs -n1 basename) + CLI_TGZ=$(ls packages/cli/*.tgz | xargs -n1 basename) + PY_VERSION=$(grep '^version' packages/python-sdk/pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + PY_WHL=$(ls packages/python-sdk/dist/*.whl | xargs -n1 basename) + + BODY=""$'\n' + BODY+="### Package Artifacts"$'\n\n' + BODY+="Built from ${GITHUB_SHA::7}. Download artifacts from [this workflow run](${RUN_URL})."$'\n\n' + BODY+="**JS SDK** (\`e2b@${JS_VERSION}\`):"$'\n' + BODY+='```sh'$'\n' + BODY+="npm install ./${JS_TGZ}"$'\n' + BODY+='```'$'\n\n' + BODY+="**CLI** (\`@e2b/cli@${CLI_VERSION}\`):"$'\n' + BODY+='```sh'$'\n' + BODY+="npm install ./${CLI_TGZ}"$'\n' + BODY+='```'$'\n\n' + BODY+="**Python SDK** (\`e2b==${PY_VERSION}\`):"$'\n' + BODY+='```sh'$'\n' + BODY+="pip install ./${PY_WHL}"$'\n' + BODY+='```'$'\n' + + COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ + --paginate \ + --jq '.[] | select(.body | contains("")) | .id' \ + | tail -1) + + if [ -n "$COMMENT_ID" ]; then + gh api "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \ + -X PATCH -f body="$BODY" + else + gh pr comment "$PR_NUMBER" --body "$BODY" + fi diff --git a/.github/workflows/publish_candidates.yml b/.github/workflows/publish_candidates.yml new file mode 100644 index 0000000..bc6aaee --- /dev/null +++ b/.github/workflows/publish_candidates.yml @@ -0,0 +1,114 @@ +name: Publish Candidates + +on: + workflow_call: + inputs: + js-sdk: + required: false + type: boolean + default: false + python-sdk: + required: false + type: boolean + default: false + cli: + required: false + type: boolean + default: false + tag: + required: true + type: string + preid: + required: true + type: string + +permissions: + contents: read + id-token: write + +jobs: + publish: + name: Publish Release Candidates + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Parse .tool-versions + uses: wistia/parse-tool-versions@v2.1.1 + with: + filename: '.tool-versions' + uppercase: 'true' + prefix: 'tool_version_' + + - uses: pnpm/action-setup@v4 + if: ${{ inputs.js-sdk || inputs.cli }} + with: + version: '${{ env.TOOL_VERSION_PNPM }}' + + - name: Setup Node.js + uses: actions/setup-node@v6 + if: ${{ inputs.js-sdk || inputs.cli }} + with: + node-version: '${{ env.TOOL_VERSION_NODEJS }}' + registry-url: https://registry.npmjs.org + cache: pnpm + + - name: Configure pnpm + if: ${{ inputs.js-sdk || inputs.cli }} + run: | + pnpm config set auto-install-peers true + pnpm config set exclude-links-from-lockfile true + + - name: Update npm + if: ${{ inputs.js-sdk || inputs.cli }} + run: | + npm install -g npm@^11.6 + npm --version + + - name: Install uv + uses: astral-sh/setup-uv@v6 + if: ${{ inputs.python-sdk }} + with: + version: '${{ env.TOOL_VERSION_UV }}' + python-version: '${{ env.TOOL_VERSION_PYTHON }}' + enable-cache: true + + - name: Publish Python RC + if: ${{ inputs.python-sdk }} + working-directory: packages/python-sdk + run: | + BASE_VERSION=$(uv version --short) + uv version "${BASE_VERSION}rc${{ github.run_id }}" + uv build + uv publish --token ${PYPI_TOKEN} --check-url https://pypi.org/simple/ + env: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + + - name: Install JS dependencies + if: ${{ inputs.js-sdk || inputs.cli }} + run: pnpm install --frozen-lockfile + + - name: Publish JS RC + if: ${{ inputs.js-sdk }} + working-directory: packages/js-sdk + env: + RC_PREID: ${{ inputs.preid }} + RC_TAG: ${{ inputs.tag }} + run: | + npm version prerelease --preid="${RC_PREID}.${{ github.run_id }}" --no-git-tag-version + npm publish --tag "$RC_TAG" --provenance + + - name: Reinstall dependencies + if: ${{ inputs.js-sdk || inputs.cli }} + run: pnpm install --frozen-lockfile + + - name: Publish CLI RC + if: ${{ inputs.cli }} + working-directory: packages/cli + env: + RC_PREID: ${{ inputs.preid }} + RC_TAG: ${{ inputs.tag }} + run: | + npm version prerelease --preid="${RC_PREID}.${{ github.run_id }}" --no-git-tag-version + npm publish --tag "$RC_TAG" --provenance diff --git a/.github/workflows/publish_packages.yml b/.github/workflows/publish_packages.yml new file mode 100644 index 0000000..7f5b584 --- /dev/null +++ b/.github/workflows/publish_packages.yml @@ -0,0 +1,111 @@ +name: Publish Packages + +on: + workflow_call: + secrets: + E2B_API_KEY: + required: true + PYPI_TOKEN: + required: true + +permissions: + contents: write + id-token: write + +jobs: + test: + name: Build and test SDK + runs-on: ubuntu-22.04 + steps: + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ vars.VERSION_BUMPER_APPID }} + private-key: ${{ secrets.VERSION_BUMPER_SECRET }} + + - name: Checkout Repo + uses: actions/checkout@v3 + with: + token: ${{ steps.app-token.outputs.token }} + + - name: Parse .tool-versions + uses: wistia/parse-tool-versions@v2.1.1 + with: + filename: '.tool-versions' + uppercase: 'true' + prefix: 'tool_version_' + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: '${{ env.TOOL_VERSION_UV }}' + python-version: '${{ env.TOOL_VERSION_PYTHON }}' + enable-cache: true + + - uses: pnpm/action-setup@v4 + with: + version: '${{ env.TOOL_VERSION_PNPM }}' + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '${{ env.TOOL_VERSION_NODEJS }}' + registry-url: 'https://registry.npmjs.org' + cache: pnpm + + - name: Configure pnpm + run: | + pnpm config set auto-install-peers true + pnpm config set exclude-links-from-lockfile true + + - name: Update npm + run: | + npm install -g npm@^11.6 + npm --version + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Create new versions + run: pnpm run version + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Release new versions + uses: changesets/action@v1 + with: + publish: pnpm run publish + createGithubReleases: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: "" # See https://github.com/changesets/changesets/issues/1152#issuecomment-3190884868 + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + + - name: Update lock file + run: pnpm i --no-link --no-frozen-lockfile + + - name: Commit new versions + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -am "[skip ci] Release new versions" || exit 0 + + # A PR merging mid-release makes this push a non-fast-forward. The + # artifacts were already published from the checked-out tree, so we + # only rebase the version bump over incoming changes that don't touch + # packages/. Otherwise the source would claim the published version + # contains code that isn't in the artifacts, so we fail loudly. + if git push; then + exit 0 + fi + + git fetch origin "${GITHUB_REF_NAME}" + if git diff --name-only "HEAD~1" FETCH_HEAD -- packages/ | grep -q .; then + echo "::error::'${GITHUB_REF_NAME}' advanced with changes under packages/ during the release. Refusing to rebase the version bump onto unpublished package code (the source would diverge from the published artifacts). Reconcile manually." + exit 1 + fi + + git pull --rebase origin "${GITHUB_REF_NAME}" + git push + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/python_sdk_tests.yml b/.github/workflows/python_sdk_tests.yml new file mode 100644 index 0000000..dab4506 --- /dev/null +++ b/.github/workflows/python_sdk_tests.yml @@ -0,0 +1,57 @@ +name: Test Python SDK + +on: + workflow_call: + inputs: + E2B_DOMAIN: + required: false + type: string + default: '' + secrets: + E2B_API_KEY: + required: true + +permissions: + contents: read + +jobs: + test: + defaults: + run: + working-directory: ./packages/python-sdk + shell: bash + name: Python SDK - Build and test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Parse .tool-versions + uses: wistia/parse-tool-versions@v2.1.1 + with: + filename: '.tool-versions' + uppercase: 'true' + prefix: 'tool_version_' + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: '${{ env.TOOL_VERSION_UV }}' + python-version: '${{ env.TOOL_VERSION_PYTHON }}' + enable-cache: true + + - name: Install dependencies + run: uv sync --locked + + - name: Test build + run: uv build + + - name: Run tests + run: uv run pytest --verbose --numprocesses=4 + env: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }} diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml new file mode 100644 index 0000000..c38e544 --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -0,0 +1,106 @@ +name: Release candidate + +on: + workflow_dispatch: + inputs: + js-sdk: + description: 'Release JS SDK' + required: false + default: false + type: boolean + python-sdk: + description: 'Release Python SDK' + required: false + default: false + type: boolean + cli: + description: 'Release CLI' + required: false + default: false + type: boolean + tag: + description: 'Dist-tag (e.g. rc, beta, snapshot)' + required: false + default: 'rc' + type: string + preid: + description: 'Prerelease identifier (defaults to branch name)' + required: false + default: '' + type: string + skip-tests: + description: 'Skip tests' + required: false + default: false + type: boolean + +# Shared literal group so production and candidate releases on the same ref serialize. +concurrency: release-${{ github.ref }} + +permissions: + id-token: write + contents: write + +jobs: + rc-validate: + name: Validate RC inputs + runs-on: ubuntu-latest + outputs: + preid: ${{ steps.preid.outputs.preid }} + tag: ${{ steps.tag.outputs.tag }} + steps: + - name: Sanitize tag + id: tag + env: + RAW_TAG: ${{ github.event.inputs.tag }} + run: | + SAFE_TAG="$(echo "$RAW_TAG" | sed 's/[^0-9A-Za-z-]/-/g')" + echo "tag=$SAFE_TAG" >> "$GITHUB_OUTPUT" + + - name: Block production tags + run: | + if [ "${{ steps.tag.outputs.tag }}" = "latest" ]; then + echo "::error::Publishing with the 'latest' tag is not allowed for candidates. Use the 'Release' workflow instead." + exit 1 + fi + + - name: Sanitize preid + id: preid + env: + RAW_PREID: ${{ github.event.inputs.preid || github.ref_name }} + run: | + echo "preid=$(echo "$RAW_PREID" | sed 's/[^0-9A-Za-z-]/-/g')" >> "$GITHUB_OUTPUT" + + rc-python-tests: + name: RC Python Tests + needs: [rc-validate] + if: github.event.inputs.python-sdk == 'true' && github.event.inputs.skip-tests != 'true' + uses: ./.github/workflows/python_sdk_tests.yml + secrets: inherit + + rc-js-tests: + name: RC JS Tests + needs: [rc-validate] + if: github.event.inputs.js-sdk == 'true' && github.event.inputs.skip-tests != 'true' + uses: ./.github/workflows/js_sdk_tests.yml + secrets: inherit + + rc-cli-tests: + name: RC CLI Tests + needs: [rc-validate] + if: github.event.inputs.cli == 'true' && github.event.inputs.skip-tests != 'true' + uses: ./.github/workflows/cli_tests.yml + secrets: inherit + + rc-publish: + name: Publish RC + needs: [rc-validate, rc-python-tests, rc-js-tests, rc-cli-tests] + if: (!cancelled()) && !contains(needs.*.result, 'failure') && needs.rc-validate.result == 'success' + uses: ./.github/workflows/publish_candidates.yml + with: + js-sdk: ${{ github.event.inputs.js-sdk == 'true' }} + python-sdk: ${{ github.event.inputs.python-sdk == 'true' }} + cli: ${{ github.event.inputs.cli == 'true' }} + tag: ${{ needs.rc-validate.outputs.tag }} + preid: ${{ needs.rc-validate.outputs.preid }} + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e6444ba --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,182 @@ +name: Release + +on: + workflow_dispatch: {} + +# Shared literal group so production and candidate releases on the same ref serialize. +concurrency: release-${{ github.ref }} + +permissions: + id-token: write + contents: write + +jobs: + preflight: + name: Release preflight + runs-on: ubuntu-latest + outputs: + release: ${{ steps.version.outputs.release }} + js-sdk: ${{ steps.js.outputs.release }} + python-sdk: ${{ steps.python.outputs.release }} + cli: ${{ steps.cli.outputs.release }} + itinerary: ${{ steps.itinerary.outputs.itinerary }} + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + + - name: Parse .tool-versions + uses: wistia/parse-tool-versions@v2.1.1 + with: + filename: '.tool-versions' + uppercase: 'true' + prefix: 'tool_version_' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + id: pnpm-install + with: + version: '${{ env.TOOL_VERSION_PNPM }}' + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '${{ env.TOOL_VERSION_NODEJS }}' + registry-url: 'https://registry.npmjs.org' + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Configure pnpm + run: | + pnpm config set auto-install-peers true + pnpm config set exclude-links-from-lockfile true + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check if new version + id: version + run: | + IS_RELEASE=$(./.github/scripts/is_release.sh) + echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT" + + - name: Check JavaScript SDK Release + id: js + if: steps.version.outputs.release == 'true' + run: | + IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "e2b") + echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT" + + - name: Check Python SDK Release + id: python + if: steps.version.outputs.release == 'true' + run: | + IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/python-sdk") + echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT" + + - name: Check CLI Release + id: cli + if: steps.version.outputs.release == 'true' + run: | + IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/cli") + echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT" + + - name: Build release itinerary + id: itinerary + if: steps.version.outputs.release == 'true' + run: | + pnpm changeset status --output=.cs-status.json + ITINERARY=$(node -e ' + const data = require("./.cs-status.json"); + const labels = { "e2b": "JS SDK (e2b)", "@e2b/python-sdk": "Python SDK (e2b)", "@e2b/cli": "CLI (@e2b/cli)" }; + const order = ["e2b", "@e2b/python-sdk", "@e2b/cli"]; + const byName = Object.fromEntries(data.releases.map(r => [r.name, r])); + const lines = order.filter(n => byName[n]).map(n => `• ${labels[n]} v${byName[n].newVersion}`); + process.stdout.write(lines.join("\n") || "• No packages were published"); + ') + rm -f .cs-status.json + { + echo "itinerary<> "$GITHUB_OUTPUT" + + python-tests: + name: Python SDK Tests + needs: [preflight] + if: needs.preflight.outputs.python-sdk == 'true' + uses: ./.github/workflows/python_sdk_tests.yml + secrets: inherit + + js-tests: + name: JS SDK Tests + needs: [preflight] + if: needs.preflight.outputs.js-sdk == 'true' + uses: ./.github/workflows/js_sdk_tests.yml + secrets: inherit + + cli-tests: + name: CLI Tests + needs: [preflight] + if: needs.preflight.outputs.cli == 'true' + uses: ./.github/workflows/cli_tests.yml + secrets: inherit + + publish: + name: Publish + needs: [preflight, python-tests, js-tests, cli-tests] + if: (!cancelled()) && !contains(needs.*.result, 'failure') && needs.preflight.outputs.release == 'true' + uses: ./.github/workflows/publish_packages.yml + secrets: inherit + + report-start: + needs: [preflight] + if: needs.preflight.outputs.release == 'true' + name: Release Started - Slack Notification + runs-on: ubuntu-latest + steps: + - name: Release Started - Slack Notification + uses: rtCamp/action-slack-notify@33ca3be66c6f378fe1610fd1d5258632dbed5e58 # v2.4.0 + env: + SLACK_COLOR: '#3aa3e3' + SLACK_MESSAGE: | + :rocket: A new release has been triggered :hourglass_flowing_sand: + + *Releasing:* + ${{ needs.preflight.outputs.itinerary }} + SLACK_TITLE: Release Started + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_CHANNEL: 'monitoring-releases' + + report-failure: + needs: [python-tests, js-tests, cli-tests, publish] + if: failure() + name: Release Failed - Slack Notification + runs-on: ubuntu-latest + steps: + - name: Release Failed - Slack Notification + uses: rtCamp/action-slack-notify@33ca3be66c6f378fe1610fd1d5258632dbed5e58 # v2.4.0 + env: + SLACK_COLOR: '#ff0000' + SLACK_MESSAGE: ':here-we-go-again: :bob-the-destroyer: We need :fix-parrot: ASAP :pray:' + SLACK_TITLE: Release Failed + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_CHANNEL: 'monitoring-releases' + + report-success: + needs: [preflight, publish] + if: needs.publish.result == 'success' + name: Release Succeeded - Slack Notification + runs-on: ubuntu-latest + steps: + - name: Release Succeeded - Slack Notification + uses: rtCamp/action-slack-notify@33ca3be66c6f378fe1610fd1d5258632dbed5e58 # v2.4.0 + env: + SLACK_COLOR: '#36a64f' + SLACK_MESSAGE: | + :tada: A new version has been released successfully! :ship-it-parrot: + + *Released:* + ${{ needs.preflight.outputs.itinerary }} + SLACK_TITLE: Release Succeeded + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_CHANNEL: 'monitoring-releases' diff --git a/.github/workflows/sdk_tests.yml b/.github/workflows/sdk_tests.yml new file mode 100644 index 0000000..290ba3c --- /dev/null +++ b/.github/workflows/sdk_tests.yml @@ -0,0 +1,133 @@ +name: SDK Tests + +on: + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + changes: + name: Detect changes + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + js: ${{ steps.filter.outputs.js }} + python: ${{ steps.filter.outputs.python }} + cli: ${{ steps.filter.outputs.cli }} + steps: + - name: Filter changed paths + # On workflow_dispatch there is no PR diff and paths-filter would fall + # back to git (failing without a checkout); the job-level `if`s below + # force a full run for that event instead, so we only filter on pull_request. + if: github.event_name == 'pull_request' + uses: dorny/paths-filter@v3 + id: filter + with: + # `**/!(*.md)` excludes Markdown so docs-only changes don't trigger the suites. + filters: | + shared: &shared + - '.github/workflows/sdk_tests.yml' + - 'spec/**/!(*.md)' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - '.tool-versions' + js: + - *shared + - '.github/workflows/js_sdk_tests.yml' + - 'packages/js-sdk/**/!(*.md)' + python: + - *shared + - '.github/workflows/python_sdk_tests.yml' + - 'packages/python-sdk/**/!(*.md)' + - 'packages/connect-python/**/!(*.md)' + cli: + - *shared + - '.github/workflows/cli_tests.yml' + - 'packages/cli/**/!(*.md)' + - 'packages/js-sdk/**/!(*.md)' + + js-tests: + name: Production / JS SDK Tests + needs: changes + if: ${{ needs.changes.outputs.js == 'true' || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/js_sdk_tests.yml + secrets: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + + python-tests: + name: Production / Python SDK Tests + needs: changes + if: ${{ needs.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/python_sdk_tests.yml + secrets: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + + cli-tests: + name: Production / CLI Tests + needs: changes + if: ${{ needs.changes.outputs.cli == 'true' || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/cli_tests.yml + secrets: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + + js-tests-staging: + name: Staging / JS SDK Tests + needs: changes + if: ${{ needs.changes.outputs.js == 'true' || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/js_sdk_tests.yml + with: + E2B_DOMAIN: ${{ vars.E2B_DOMAIN_STAGING }} + secrets: + E2B_API_KEY: ${{ secrets.E2B_API_KEY_STAGING }} + + python-tests-staging: + name: Staging / Python SDK Tests + needs: changes + if: ${{ needs.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/python_sdk_tests.yml + with: + E2B_DOMAIN: ${{ vars.E2B_DOMAIN_STAGING }} + secrets: + E2B_API_KEY: ${{ secrets.E2B_API_KEY_STAGING }} + + cli-tests-staging: + name: Staging / CLI Tests + needs: changes + if: ${{ needs.changes.outputs.cli == 'true' || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/cli_tests.yml + with: + E2B_DOMAIN: ${{ vars.E2B_DOMAIN_STAGING }} + secrets: + E2B_API_KEY: ${{ secrets.E2B_API_KEY_STAGING }} + + status: + name: SDK Tests Status + # `changes` is included so a failure in change-detection (which would skip the + # suites) surfaces as a failed required check instead of a false green. + needs: + - changes + - js-tests + - python-tests + - cli-tests + if: ${{ always() }} + runs-on: ubuntu-latest + steps: + - name: Report aggregate result + run: | + echo "Job results: ${{ join(needs.*.result, ', ') }}" + if ${{ contains(needs.*.result, 'failure') }}; then + echo "::error::Change detection or a Production SDK test suite failed." + exit 1 + fi + if ${{ contains(needs.*.result, 'cancelled') }}; then + echo "::error::Change detection or a Production SDK test suite was cancelled; tests were not verified — re-run before merging." + exit 1 + fi + echo "All Production SDK test suites passed or were skipped." diff --git a/.github/workflows/templates.yml b/.github/workflows/templates.yml new file mode 100644 index 0000000..7bc56e7 --- /dev/null +++ b/.github/workflows/templates.yml @@ -0,0 +1,52 @@ +name: Build and push prepared templates + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + buildAndPushImage: + defaults: + run: + working-directory: ./templates/base + + name: Build and Push Image to DockerHub + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + run: | + docker buildx build \ + --file e2b.Dockerfile \ + --platform linux/amd64,linux/arm64 \ + --push \ + --tag ${{ secrets.DOCKERHUB_USERNAME }}/base:latest . + + buildTemplate: + name: Build and Publish E2B Template + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build and install E2B CLI + uses: ./.github/actions/build-cli + + - name: Build and publish base template + working-directory: ./templates/base + env: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + run: e2b template create base --memory-mb 512 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 0000000..4ae2511 --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,82 @@ +name: Typecheck + +on: + pull_request: + +jobs: + changes: + name: Detect changes + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - name: Filter changed paths + uses: dorny/paths-filter@v3 + id: filter + with: + # `**/!(*.md)` excludes Markdown so docs-only changes don't trigger typecheck. + filters: | + code: + - 'packages/**/!(*.md)' + - 'spec/**/!(*.md)' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - '.tool-versions' + - '.github/workflows/typecheck.yml' + + typecheck: + name: Typecheck + needs: changes + if: ${{ needs.changes.outputs.code == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Parse .tool-versions + uses: wistia/parse-tool-versions@v2.1.1 + with: + filename: '.tool-versions' + uppercase: 'true' + prefix: 'tool_version_' + + - uses: pnpm/action-setup@v4 + with: + version: '${{ env.TOOL_VERSION_PNPM }}' + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '${{ env.TOOL_VERSION_NODEJS }}' + cache: pnpm + + - name: Configure pnpm + run: | + pnpm config set auto-install-peers true + pnpm config set exclude-links-from-lockfile true + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: '${{ env.TOOL_VERSION_UV }}' + python-version: '${{ env.TOOL_VERSION_PYTHON }}' + enable-cache: true + + - name: Install Python dependencies + working-directory: packages/python-sdk + run: | + uv sync --locked + + - name: Run typecheck + run: | + pnpm run typecheck diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ccb79c --- /dev/null +++ b/.gitignore @@ -0,0 +1,297 @@ +vale-styles +.vercel +.vscode +.DS_Store + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.template +!.env.example +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# SDK reference artifacts +sdk_ref/ +sdkRefRoutes.json + +# SDK Client generated spec +spec/openapi_generated.yml diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..4e82b99 --- /dev/null +++ b/.npmrc @@ -0,0 +1,6 @@ +enable-pre-post-scripts=true +auto-install-peers=true +exclude-links-from-lockfile=true +prefer-workspace-packages=false +link-workspace-packages=false +engine-strict=true diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..a5c79d1 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", + "ignorePatterns": ["**/dist/**", "**/node_modules/**", "**/*.gen.ts"], + "rules": { + "no-unused-vars": ["error", { "caughtErrors": "none" }], + "typescript/no-explicit-any": "off", + "typescript/ban-ts-comment": "off", + "unicorn/no-useless-fallback-in-spread": "off" + } +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..bfbf1d0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +/.next/ + +# imporatant to keep // $HighlightLine comments at the end of the line +apps/web/src/code/ + + +**/*.mdx +**/code/**/* diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0b49510 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "semi": false, + "trailingComma": "es5" +} diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..fc98f2e --- /dev/null +++ b/.tool-versions @@ -0,0 +1,5 @@ +deno 1.46.3 +nodejs 22.18.0 +pnpm 9.15.5 +python 3.10 +uv 0.10.0 diff --git a/.vale.ini b/.vale.ini new file mode 100644 index 0000000..5fa2919 --- /dev/null +++ b/.vale.ini @@ -0,0 +1,8 @@ +StylesPath = vale-styles + +MinAlertLevel = suggestion + +Packages = Microsoft, proselint, write-good, alex, Readability, Joblint, Google + +[*.{md,ts,py}] +BasedOnStyles = Vale, Microsoft, proselint, write-good, alex, Readability, Joblint, Google \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6358702 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,9 @@ +Use pnpm for node and uv for python to install and update dependencies. +Run `pnpm run format`, `pnpm run lint` and `pnpm run typecheck` before committing changes. +When modifying the SDK packages, ensure equivalent changes are applied to both JS as well as sync and async Python implementations. +To re-generate the API client run `make codegen` in the repository root when modifying spec/. +Create or update tests covering affected codepaths and run them using `pnpm run test`. +Generate a changeset when updating packages/cli, packages/js-sdk, packages/python-sdk with `pnpm changeset` in the repository root. +When creating a pull request, add usage examples for user-facing changes to the PR description. +Keep PR descriptions up-to-date with changes. +Default credentials are stored in .env.local in the repository root or inside ~/.e2b/config.json. diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..419efc9 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,7 @@ +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence. +* @jakubno @ValentaTomas + +# SDKs +packages @jakubno @ValentaTomas @mishushakov +packages/connect-python @ValentaTomas diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ff3e758 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,2 @@ +# Contributing +If you want to contribute, open a PR, issue, or start a discussion on our [Discord](https://discord.gg/dSBY3ms2Qr). diff --git a/DEV.md b/DEV.md new file mode 100644 index 0000000..ef83fd7 --- /dev/null +++ b/DEV.md @@ -0,0 +1,3 @@ +# Releasing e2b cli + +to create a changeset run `pnpm run changeset` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ec47fef --- /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 2023 FoundryLabs, Inc. + + 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/Makefile b/Makefile new file mode 100644 index 0000000..1027af6 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: codegen +codegen: + @echo "Building codegen image" + docker build -q -t codegen-env . -f codegen.Dockerfile + @echo "Generating code" + docker run -v $$PWD:/workspace codegen-env make generate + +generate: generate-js generate-python + +generate-js: + cd packages/js-sdk && pnpm generate + +generate-python: + cd packages/python-sdk && make generate + +.PHONY: init-styles +init-styles: + vale sync + diff --git a/README.md b/README.md new file mode 100644 index 0000000..c71572d --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +

+ E2B Logo + E2B Logo +

+ +

+ + Last 1 month downloads for the Python SDK + + + Last 1 month downloads for the JavaScript SDK + +

+ + +## What is E2B? +[E2B](https://e2b.dev/?utm_source=github&utm_medium=referral&utm_campaign=readme&utm_content=E2B) is an open-source infrastructure that allows you to run AI-generated code in secure isolated sandboxes in the cloud. To start and control sandboxes, use our [JavaScript SDK](https://www.npmjs.com/package/e2b) or [Python SDK](https://pypi.org/project/e2b). + +## Run your first Sandbox + +### 1. Install SDK + +JavaScript / TypeScript +``` +npm i e2b +``` + +Python +``` +pip install e2b +``` + +### 2. Get your E2B API key +1. Sign up to E2B [here](https://e2b.dev/?utm_source=github&utm_medium=referral&utm_campaign=readme&utm_content=E2B). +2. Get your API key [here](https://e2b.dev/dashboard?tab=keys&utm_source=github&utm_medium=referral&utm_campaign=readme&utm_content=E2B). +3. Set environment variable with your API key +``` +E2B_API_KEY=e2b_*** +``` + +### 3. Start a sandbox and run commands + +JavaScript / TypeScript +```ts +import Sandbox from 'e2b' + +const sandbox = await Sandbox.create() +const result = await sandbox.commands.run('echo "Hello from E2B!"') +console.log(result.stdout) // Hello from E2B! +``` + +Python +```py +from e2b import Sandbox + +with Sandbox.create() as sandbox: + result = sandbox.commands.run('echo "Hello from E2B!"') + print(result.stdout) # Hello from E2B! +``` + +### 4. Code execution with Code Interpreter + +If you need to execute code with [`runCode()`](https://e2b.dev/docs/code-interpreting?utm_source=github&utm_medium=referral&utm_campaign=readme&utm_content=E2B)/[`run_code()`](https://e2b.dev/docs/code-interpreting?utm_source=github&utm_medium=referral&utm_campaign=readme&utm_content=E2B), install the [Code Interpreter SDK](https://github.com/e2b-dev/code-interpreter): + +``` +npm i @e2b/code-interpreter # JavaScript/TypeScript +pip install e2b-code-interpreter # Python +``` + +```ts +import { Sandbox } from '@e2b/code-interpreter' + +const sandbox = await Sandbox.create() +const execution = await sandbox.runCode('x = 1; x += 1; x') +console.log(execution.text) // outputs 2 +``` + +### 5. Check docs +Visit [E2B documentation](https://e2b.dev/docs?utm_source=github&utm_medium=referral&utm_campaign=readme&utm_content=E2B). + +### 6. E2B cookbook +Visit our [Cookbook](https://github.com/e2b-dev/e2b-cookbook/tree/main) to get inspired by examples with different LLMs and AI frameworks. + +## Self-hosting + +Read the [self-hosting guide](https://github.com/e2b-dev/infra/blob/main/self-host.md) to learn how to set up the [E2B infrastructure](https://github.com/e2b-dev/infra) on your own. The infrastructure is deployed using Terraform. + +Supported cloud providers: +- 🟢 AWS +- 🟢 Google Cloud (GCP) +- [ ] Azure +- [ ] General Linux machine diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..c45515e --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`e2b-dev/E2B` +- 原始仓库:https://github.com/e2b-dev/E2B +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/codegen.Dockerfile b/codegen.Dockerfile new file mode 100644 index 0000000..72e6bea --- /dev/null +++ b/codegen.Dockerfile @@ -0,0 +1,56 @@ +FROM golang:1.23 + +# Install Golang deps +RUN go install github.com/bufbuild/buf/cmd/buf@v1.50.1 && \ + go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28.1 && \ + go install connectrpc.com/connect/cmd/protoc-gen-connect-go@v1.18.1 + +# Install our custom protoc plugin, connect-python +COPY ./packages/connect-python /packages/connect-python +RUN cd /packages/connect-python && make bin/protoc-gen-connect-python + + +FROM python:3.10 + +# Set working directory +WORKDIR /workspace + +ENV PROTOC_VERSION=26.1 +RUN ARCH=$(uname -m) && \ + case "$ARCH" in \ + x86_64) PROTOC_ARCH="x86_64" ;; \ + arm64|aarch64) PROTOC_ARCH="aarch_64" ;; \ + *) echo "Unsupported architecture: $ARCH" && exit 1 ;; \ + esac && \ + curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip && \ + unzip -o protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip -d /usr/local && \ + rm protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip + +# Copy installed Go deps from previous build step +COPY --from=0 /go /go + +# Add Go binary to PATH +ENV PATH="/go/bin:${PATH}" + +# Install Python deps (e2b-openapi-python-client is patched version to fix issue with explode) +# https://github.com/openapi-generators/openapi-python-client/pull/1296 +RUN pip install black==26.3.1 pyyaml==6.0.2 e2b-openapi-python-client==0.26.2 datamodel-code-generator==0.34.0 + +# Install Node.js (pinned to match .tool-versions) +ENV NODE_VERSION=22.18.0 +RUN ARCH=$(uname -m) && \ + case "$ARCH" in \ + x86_64) NODE_ARCH="x64" ;; \ + arm64|aarch64) NODE_ARCH="arm64" ;; \ + *) echo "Unsupported architecture: $ARCH" && exit 1 ;; \ + esac && \ + curl -fsSL https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz | tar -xJ -C /usr/local --strip-components=1 + +# Install Node.js deps +ENV PNPM_VERSION=9.15.5 +RUN npm install -g \ + pnpm@${PNPM_VERSION} \ + @connectrpc/protoc-gen-connect-es@1.6.1 \ + @bufbuild/protoc-gen-es@2.6.2 + +CMD ["make", "generate"] diff --git a/package.json b/package.json new file mode 100644 index 0000000..a7a1676 --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "e2b", + "private": true, + "scripts": { + "version": "pnpm changeset version && pnpm run -r postVersion", + "publish": "pnpm changeset publish && pnpm run -r postPublish", + "test": "pnpm test --recursive --if-present", + "rm-node-modules": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +", + "pnpm-install-hack": "cd packages/js-sdk && sed -i '' 's/\"version\": \".*\"/\"version\": \"9.9.9\"/g' package.json && cd ../.. && pnpm i && git checkout -- packages/js-sdk/package.json", + "lint": "pnpm --if-present --recursive run lint", + "typecheck": "pnpm --if-present --recursive run typecheck", + "format": "pnpm --if-present --recursive run format", + "changeset": "pnpm dlx @changesets/cli" + }, + "packageManager": "pnpm@9.15.5", + "dependencies": { + "@changesets/read": "^0.6.2" + }, + "devDependencies": { + "changeset": "^0.2.6", + "oxlint": "^1.72.0" + }, + "engines": { + "pnpm": ">=9.0.0 <10" + }, + "pnpm": { + "overrides": { + "@next/eslint-plugin-next>glob@*": "10.5.0", + "rollup@>=4": ">=4.59.0", + "postcss@<8.5.10": "^8.5.10", + "vite@>=6.0.0 <6.4.2": "^6.4.2", + "lodash@<4.18.0": "^4.18.0", + "brace-expansion@>=2.0.0 <2.0.3": "^2.0.3", + "picomatch@<2.3.2": "^2.3.2", + "picomatch@>=4.0.0 <4.0.4": "^4.0.4", + "yaml@>=2.0.0 <2.8.3": "^2.8.3", + "@tootallnate/once@<3.0.1": "^3.0.1", + "smol-toml@<1.6.1": "^1.6.1", + "flatted@<3.4.2": "^3.4.2", + "minimatch@<3.1.3": "^3.1.3", + "minimatch@>=5.0.0 <5.1.8": "^5.1.8", + "minimatch@>=9.0.0 <9.0.7": "^9.0.7", + "minimatch@>=10.0.0 <10.2.3": "^10.2.3", + "ws@>=8.0.0 <8.20.1": "^8.20.1", + "shell-quote@<1.8.4": "^1.8.4" + } + } +} diff --git a/packages/cli/.envrc b/packages/cli/.envrc new file mode 100644 index 0000000..5241869 --- /dev/null +++ b/packages/cli/.envrc @@ -0,0 +1,3 @@ +source_up + +dotenv ../../../.env.local diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore new file mode 100644 index 0000000..5bfd14d --- /dev/null +++ b/packages/cli/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +tests/temp +coverage +/.pnp +.pnp.js + +# testing +/coverage + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# generate output +dist + +# compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +/test.md +logs.txt +/dist +/files \ No newline at end of file diff --git a/packages/cli/.prettierignore b/packages/cli/.prettierignore new file mode 100644 index 0000000..95ebb00 --- /dev/null +++ b/packages/cli/.prettierignore @@ -0,0 +1,2 @@ +*.hbs +tests/**/fixtures/**/* \ No newline at end of file diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000..d7beaa9 --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2025 FOUNDRYLABS, INC. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..a8cad29 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,44 @@ +

+ + + + E2B Logo + +

+ +# E2B CLI + +This CLI tool allows you to build manager your running E2B sandbox and sandbox templates. Learn more in [our documentation](https://e2b.dev/docs). + +### 1. Install the CLI + +**Using Homebrew (on macOS)** + +```bash +brew install e2b +``` + +**Using NPM** + +```bash +npm install -g @e2b/cli +``` + +### 2. Authenticate + +```bash +e2b auth login +``` + +> [!NOTE] +> To authenticate without the ability to open the browser, provide +> `E2B_ACCESS_TOKEN` as an environment variable. You can find your token +> in Account Settings under the Team selector at [e2b.dev/dashboard](https://e2b.dev/dashboard). Then use the CLI like this: +> `E2B_ACCESS_TOKEN=sk_e2b_... e2b template create`. + +> [!IMPORTANT] +> Note the distinction between `E2B_ACCESS_TOKEN` and `E2B_API_KEY`. + +### 3. Check out docs + +Visit our [CLI documentation](https://e2b.dev/docs) to learn more. diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..4539977 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,94 @@ +{ + "name": "@e2b/cli", + "version": "2.13.1", + "description": "CLI for managing e2b sandbox templates", + "homepage": "https://e2b.dev", + "license": "MIT", + "author": { + "name": "FoundryLabs, Inc.", + "email": "hello@e2b.dev", + "url": "https://e2b.dev" + }, + "bugs": "https://github.com/e2b-dev/e2b/issues", + "repository": { + "type": "git", + "url": "https://github.com/e2b-dev/e2b", + "directory": "packages/cli" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "e2b", + "ai-agents", + "agents", + "ai", + "code-interpreter", + "sandbox", + "code", + "cli", + "runtime", + "vm", + "nodejs", + "javascript", + "typescript" + ], + "sideEffects": false, + "scripts": { + "prepublishOnly": "pnpm build", + "build": "tsc --noEmit --skipLibCheck && tsdown --minify", + "dev": "tsdown --watch", + "typecheck": "tsc --noEmit --skipLibCheck", + "lint": "oxlint --config ../../.oxlintrc.json src", + "format": "prettier --write src", + "test:interactive": "pnpm build && ./dist/index.js", + "test": "vitest run", + "test:watch": "vitest watch", + "test:coverage": "vitest run --coverage", + "check-deps": "knip" + }, + "devDependencies": { + "@types/handlebars": "^4.1.0", + "@types/inquirer": "^9.0.7", + "@types/json2md": "^1.5.4", + "@types/node": "^20.19.19", + "@types/npmcli__package-json": "^4.0.4", + "@types/statuses": "^2.0.5", + "@typescript/native": "npm:typescript@^7.0.2", + "@vitest/coverage-v8": "^4.1.0", + "json2md": "^2.0.1", + "knip": "^5.43.6", + "tsdown": "^0.22.3", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "vitest": "^4.1.8" + }, + "files": [ + "dist", + "LICENSE", + "README", + "package.json" + ], + "bin": { + "e2b": "dist/index.js" + }, + "dependencies": { + "@iarna/toml": "^2.2.5", + "@inquirer/prompts": "^7.9.0", + "@npmcli/package-json": "^5.2.1", + "async-listen": "^3.0.1", + "boxen": "^7.1.1", + "chalk": "^5.3.0", + "cli-highlight": "^2.1.11", + "commander": "^11.1.0", + "console-table-printer": "^2.11.2", + "e2b": "^2.32.0", + "handlebars": "^4.7.9", + "inquirer": "^12.10.0", + "simple-update-notifier": "^2.0.0", + "statuses": "^2.0.1", + "yup": "^1.3.2" + }, + "engines": { + "node": ">=20.18.1 <21 || >=22" + } +} diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts new file mode 100644 index 0000000..5ed5238 --- /dev/null +++ b/packages/cli/src/api.ts @@ -0,0 +1,114 @@ +import * as boxen from 'boxen' +import * as e2b from 'e2b' + +import { getUserConfig, UserConfig } from './user' +import { asBold, asPrimary } from './utils/format' + +export type Teams = + e2b.paths['/teams']['get']['responses'][200]['content']['application/json'] + +export let apiKey = process.env.E2B_API_KEY +export let accessToken = process.env.E2B_ACCESS_TOKEN +export const teamId = process.env.E2B_TEAM_ID + +const authErrorBox = (keyName: 'E2B_API_KEY' | 'E2B_ACCESS_TOKEN') => { + const link = + keyName === 'E2B_API_KEY' + ? 'https://e2b.dev/dashboard?tab=keys' + : 'https://e2b.dev/dashboard?tab=personal' + const msg = keyName === 'E2B_API_KEY' ? 'API key' : 'access token' + const body = `You must be logged in to use this command. Run ${asBold( + 'e2b auth login' + )}. + +If you are seeing this message in CI/CD you may need to set the ${asBold( + keyName + )} environment variable. +Visit ${asPrimary(link)} to get the ${msg}.` + return boxen.default(body, { + width: 70, + float: 'center', + padding: 0.5, + margin: 1, + borderStyle: 'round', + borderColor: 'redBright', + }) +} + +export function ensureAPIKey() { + // If apiKey is not already set (either from env var or from user config), try to get it from config file + if (!apiKey) { + const userConfig = getUserConfig() + apiKey = userConfig?.teamApiKey + } + + if (!apiKey) { + console.error(authErrorBox('E2B_API_KEY')) + process.exit(1) + } else { + return apiKey + } +} + +export function ensureUserConfig(): UserConfig { + const userConfig = getUserConfig() + if (!userConfig) { + console.error('No user config found, run `e2b auth login` to log in first.') + process.exit(1) + } + return userConfig +} + +export function ensureAccessToken() { + // If accessToken is not already set (either from env var or from user config), try to get it from config file + if (!accessToken) { + const userConfig = getUserConfig() + accessToken = userConfig?.tokens.access_token + } + + if (!accessToken) { + console.error(authErrorBox('E2B_ACCESS_TOKEN')) + process.exit(1) + } else { + return accessToken + } +} + +/** + * Resolve team ID with proper precedence: + * 1. CLI --team flag + * 2. E2B_TEAM_ID env var + * 3. Local e2b.toml team_id (if provided) + * 4. ~/.e2b/config.json teamId (only if E2B_API_KEY env var is NOT set, + * to avoid mismatch between env var API key and config file team ID) + */ +export function resolveTeamId( + cliTeamId?: string, + localConfigTeamId?: string +): string | undefined { + if (cliTeamId) return cliTeamId + if (teamId) return teamId + if (localConfigTeamId) return localConfigTeamId + if (!process.env.E2B_API_KEY) { + const config = getUserConfig() + return config?.teamId + } + return undefined +} + +const userConfig = getUserConfig() + +const resolvedAccessToken = + process.env.E2B_ACCESS_TOKEN || userConfig?.tokens.access_token + +export const connectionConfig = new e2b.ConnectionConfig({ + apiKey: process.env.E2B_API_KEY || userConfig?.teamApiKey, + apiHeaders: resolvedAccessToken + ? { Authorization: `Bearer ${resolvedAccessToken}` } + : undefined, +}) +// The CLI authenticates team-scoped endpoints (e.g. `/teams`) with the access +// token instead of an API key, so don't require an API key here. +export const client = new e2b.ApiClient(connectionConfig, { + requireApiKey: false, +}) diff --git a/packages/cli/src/commands/auth/configure.ts b/packages/cli/src/commands/auth/configure.ts new file mode 100644 index 0000000..ed675d6 --- /dev/null +++ b/packages/cli/src/commands/auth/configure.ts @@ -0,0 +1,68 @@ +import * as commander from 'commander' +import * as fs from 'fs' +import * as chalk from 'chalk' +import * as e2b from 'e2b' + +import { + USER_CONFIG_PATH, + getConfigRefreshTimestamp, + writeUserConfig, +} from 'src/user' +import { ensureUserConfig, Teams } from 'src/api' +import { ensureValidAccessToken } from 'src/utils/token-refresh' +import { asBold, asFormattedTeam } from '../../utils/format' +import { handleE2BRequestError } from '../../utils/errors' + +export const configureCommand = new commander.Command('configure') + .description('configure user') + .action(async () => { + const inquirer = await import('inquirer') + + console.log('Configuring user...\n') + + if (!fs.existsSync(USER_CONFIG_PATH)) { + console.log('No user config found, run `e2b auth login` to log in first.') + return + } + + // ensureValidAccessToken may refresh tokens and write them to disk. + // Re-read the config afterwards so we persist the refreshed tokens + // instead of overwriting them with stale in-memory copies. + const accessToken = await ensureValidAccessToken() + const userConfig = ensureUserConfig() + + const config = new e2b.ConnectionConfig({ + apiHeaders: { Authorization: `Bearer ${accessToken}` }, + }) + const authClient = new e2b.ApiClient(config, { requireApiKey: false }) + + const res = await authClient.api.GET('/teams', { + signal: config.getSignal(), + }) + + handleE2BRequestError(res, 'Error getting teams') + const teams = res.data as Teams + + const team = ( + await inquirer.default.prompt([ + { + name: 'team', + message: chalk.default.underline('Select team'), + type: 'list', + pageSize: 50, + choices: teams.map((team) => ({ + name: asFormattedTeam(team, userConfig.teamId), + value: team, + })), + }, + ]) + )['team'] + + userConfig.teamName = team.name + userConfig.teamId = team.teamID + userConfig.teamApiKey = team.apiKey + userConfig.last_refresh = getConfigRefreshTimestamp() + writeUserConfig(USER_CONFIG_PATH, userConfig) + + console.log(`Team ${asBold(team.name)} (${team.teamID}) selected.\n`) + }) diff --git a/packages/cli/src/commands/auth/index.ts b/packages/cli/src/commands/auth/index.ts new file mode 100644 index 0000000..1cfb43b --- /dev/null +++ b/packages/cli/src/commands/auth/index.ts @@ -0,0 +1,12 @@ +import * as commander from 'commander' +import { loginCommand } from './login' +import { logoutCommand } from './logout' +import { infoCommand } from './info' +import { configureCommand } from './configure' + +export const authCommand = new commander.Command('auth') + .description('authentication commands') + .addCommand(loginCommand) + .addCommand(logoutCommand) + .addCommand(infoCommand) + .addCommand(configureCommand) diff --git a/packages/cli/src/commands/auth/info.ts b/packages/cli/src/commands/auth/info.ts new file mode 100644 index 0000000..59ad261 --- /dev/null +++ b/packages/cli/src/commands/auth/info.ts @@ -0,0 +1,24 @@ +import * as commander from 'commander' + +import { getUserConfig } from 'src/user' +import { asFormattedConfig, asFormattedError } from 'src/utils/format' + +export const infoCommand = new commander.Command('info') + .description('get information about the current user') + .action(async () => { + let userConfig + try { + userConfig = getUserConfig() + } catch (err) { + console.error(asFormattedError('Failed to read user config', err)) + } + + if (!userConfig) { + console.log('Not logged in') + return + } + + console.log(asFormattedConfig(userConfig)) + + process.exit(0) + }) diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts new file mode 100644 index 0000000..eb71cec --- /dev/null +++ b/packages/cli/src/commands/auth/login.ts @@ -0,0 +1,169 @@ +import * as listen from 'async-listen' +import * as commander from 'commander' +import * as http from 'http' +import * as e2b from 'e2b' + +import { pkg } from 'src' +import { + DOCS_BASE, + getConfigRefreshTimestamp, + getUserConfig, + writeUserConfig, + USER_CONFIG_PATH, + UserConfig, +} from 'src/user' +import { asBold, asFormattedConfig, asFormattedError } from 'src/utils/format' +import { openUrlInBrowser } from 'src/utils/openBrowser' +import { connectionConfig, Teams } from 'src/api' +import { handleE2BRequestError } from '../../utils/errors' + +export const loginCommand = new commander.Command('login') + .description('log in to CLI') + .action(async () => { + let userConfig: UserConfig | null = null + + try { + userConfig = getUserConfig() + } catch (err) { + console.error(asFormattedError('Failed to read user config', err)) + } + if (userConfig) { + console.log( + `\nAlready logged in. ${asFormattedConfig( + userConfig + )}.\n\nIf you want to log in as a different user, log out first by running 'e2b auth logout'.\nTo change the team, run 'e2b auth configure'.\n` + ) + return + } else if (!userConfig) { + console.log('Attempting to log in...') + const signInResponse = await signInWithBrowser() + if (!signInResponse) { + console.info('Login aborted') + return + } + + const accessToken = signInResponse.accessToken + + const signal = connectionConfig.getSignal() + const config = new e2b.ConnectionConfig({ + apiHeaders: { Authorization: `Bearer ${accessToken}` }, + }) + const client = new e2b.ApiClient(config, { requireApiKey: false }) + const res = await client.api.GET('/teams', { + signal, + }) + + handleE2BRequestError(res, 'Error getting teams') + const teams = res.data as Teams + + const defaultTeam = teams.find((team) => team.isDefault) + if (!defaultTeam) { + console.error( + asFormattedError('No default team found, please contact support') + ) + process.exit(1) + } + + userConfig = { + version: 1, + identity: { + email: signInResponse.email, + }, + oauth: { + token_endpoint: signInResponse.tokenEndpoint, + revoke_endpoint: signInResponse.revokeEndpoint, + client_id: signInResponse.clientId, + }, + tokens: { + access_token: accessToken, + refresh_token: signInResponse.refreshToken, + }, + last_refresh: getConfigRefreshTimestamp(), + teamName: defaultTeam.name, + teamId: defaultTeam.teamID, + teamApiKey: defaultTeam.apiKey, + } + + writeUserConfig(USER_CONFIG_PATH, userConfig) + } + + console.log( + `Logged in as ${asBold( + userConfig.identity.email + )} with selected team ${asBold(userConfig.teamName)}` + ) + process.exit(0) + }) + +interface SignInWithBrowserResponse { + email: string + accessToken: string + refreshToken: string + tokenEndpoint: string + revokeEndpoint: string + clientId: string +} + +async function signInWithBrowser(): Promise { + const server = http.createServer() + const { port } = await listen.default(server, 0, '127.0.0.1') + const loginUrl = new URL(`${DOCS_BASE}/api/cli`) + loginUrl.searchParams.set('next', `http://localhost:${port}`) + loginUrl.searchParams.set('cliVersion', pkg.version) + + return new Promise((resolve, reject) => { + server.once('request', (req, res) => { + // Close the HTTP connection to prevent `server.close()` from hanging + res.setHeader('connection', 'close') + const followUpUrl = new URL(`${DOCS_BASE}/api/cli`) + const searchParams = new URL(req.url || '/', 'http://localhost') + .searchParams + const searchParamsObj = Object.fromEntries( + searchParams.entries() + ) as unknown as SignInWithBrowserResponse & { + error?: string + } + const { error } = searchParamsObj + if (error) { + reject(new Error(error)) + followUpUrl.searchParams.set('state', 'error') + followUpUrl.searchParams.set('error', error) + } else if ( + !searchParamsObj.email || + !searchParamsObj.accessToken || + !searchParamsObj.refreshToken || + !searchParamsObj.tokenEndpoint || + !searchParamsObj.revokeEndpoint || + !searchParamsObj.clientId + ) { + reject(new Error('Incomplete login response from server')) + followUpUrl.searchParams.set('state', 'error') + followUpUrl.searchParams.set( + 'error', + 'Incomplete login response from server' + ) + } else { + resolve(searchParamsObj) + followUpUrl.searchParams.set('state', 'success') + followUpUrl.searchParams.set('email', searchParamsObj.email!) + } + + res.statusCode = 302 + res.setHeader('location', followUpUrl.href) + res.end() + }) + + let manualUrlPrinted = false + const printManualUrl = () => { + if (manualUrlPrinted) return + manualUrlPrinted = true + console.log( + `\nCould not open a browser automatically. Please open the following URL manually to continue:\n\n${loginUrl.toString()}\n\nIf interactive login is unavailable, you can also authenticate by setting the ${asBold( + 'E2B_API_KEY' + )} environment variable instead.\n` + ) + } + + openUrlInBrowser(loginUrl.toString(), printManualUrl) + }) +} diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts new file mode 100644 index 0000000..57ce043 --- /dev/null +++ b/packages/cli/src/commands/auth/logout.ts @@ -0,0 +1,36 @@ +import * as commander from 'commander' +import * as fs from 'fs' + +import { getUserConfig, USER_CONFIG_PATH } from 'src/user' + +export const logoutCommand = new commander.Command('logout') + .description('log out of CLI') + .action(async () => { + if (!fs.existsSync(USER_CONFIG_PATH)) { + console.log('Not logged in, nothing to do') + return + } + + let config + try { + config = getUserConfig() + } catch { + // Malformed config file — proceed to delete it below + } + if (config) { + await fetch(config.oauth.revoke_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + token: config.tokens.refresh_token, + token_type_hint: 'refresh_token', + client_id: config.oauth.client_id, + }), + }).catch(() => {}) + } + + if (fs.existsSync(USER_CONFIG_PATH)) { + fs.unlinkSync(USER_CONFIG_PATH) + } + console.log('Logged out.') + }) diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts new file mode 100644 index 0000000..aeee06b --- /dev/null +++ b/packages/cli/src/commands/index.ts @@ -0,0 +1,21 @@ +import * as commander from 'commander' + +import { asPrimary } from 'src/utils/format' +import { templateCommand } from './template' +import { sandboxCommand } from './sandbox' +import { authCommand } from './auth' + +export const program = new commander.Command() + .description( + `Create sandbox templates from Dockerfiles by running ${asPrimary( + 'e2b template create' + )} then use our SDKs to create sandboxes from these templates. + +Visit ${asPrimary( + 'E2B docs (https://e2b.dev/docs)' + )} to learn how to create sandbox templates and start sandboxes. +` + ) + .addCommand(authCommand) + .addCommand(templateCommand) + .addCommand(sandboxCommand) diff --git a/packages/cli/src/commands/sandbox/connect.ts b/packages/cli/src/commands/sandbox/connect.ts new file mode 100644 index 0000000..1f6ba0b --- /dev/null +++ b/packages/cli/src/commands/sandbox/connect.ts @@ -0,0 +1,50 @@ +import * as e2b from 'e2b' +import * as commander from 'commander' + +import { spawnConnectedTerminal } from 'src/terminal' +import { asBold, asPrimary } from 'src/utils/format' +import { ensureAPIKey } from '../../api' +import { printDashboardSandboxInspectUrl } from 'src/utils/urls' + +export const connectCommand = new commander.Command('connect') + .description('connect terminal to already running sandbox') + .argument('', `connect to sandbox with ${asBold('')}`) + .alias('cn') + .action(async (sandboxID: string) => { + try { + const apiKey = ensureAPIKey() + + if (!sandboxID) { + console.error('You need to specify sandbox ID') + process.exit(1) + } + + await connectToSandbox({ apiKey, sandboxID }) + // We explicitly call exit because the sandbox is keeping the program alive. + // We also don't want to call sandbox.close because that would disconnect other users from the edit session. + process.exit(0) + } catch (err: any) { + console.error(err) + process.exit(1) + } + }) + +async function connectToSandbox({ + apiKey, + sandboxID, +}: { + apiKey: string + sandboxID: string +}) { + const sandbox = await e2b.Sandbox.connect(sandboxID, { apiKey }) + + printDashboardSandboxInspectUrl(sandbox.sandboxId) + + console.log( + `Terminal connecting to sandbox ${asPrimary(`${sandbox.sandboxId}`)}` + ) + await spawnConnectedTerminal(sandbox) + console.log( + `Closing terminal connection to sandbox ${asPrimary(sandbox.sandboxId)}` + ) +} diff --git a/packages/cli/src/commands/sandbox/create.ts b/packages/cli/src/commands/sandbox/create.ts new file mode 100644 index 0000000..6cf856e --- /dev/null +++ b/packages/cli/src/commands/sandbox/create.ts @@ -0,0 +1,209 @@ +import * as e2b from 'e2b' +import * as commander from 'commander' +import * as path from 'path' + +import { ensureAPIKey } from 'src/api' +import { spawnConnectedTerminal } from 'src/terminal' +import { asBold, asFormattedSandboxTemplate } from 'src/utils/format' +import { getRoot } from '../../utils/filesystem' +import { getConfigPath, loadConfig } from '../../config' +import fs from 'fs' +import { configOption, pathOption } from '../../options' +import { printDashboardSandboxInspectUrl } from 'src/utils/urls' + +type SandboxLifecycle = { + onTimeout: 'pause' | 'kill' + autoResume?: boolean +} + +const MIN_TIMEOUT_MS = 30_000 + +export function createCommand( + name: string, + alias: string, + deprecated: boolean +) { + return new commander.Command(name) + .description('create sandbox and connect terminal to it') + .argument( + '[template]', + `create and connect to sandbox specified by ${asBold('[template]')}` + ) + .addOption(pathOption) + .addOption(configOption) + .option('-d, --detach', 'create sandbox without connecting terminal to it') + .option( + '--lifecycle.ontimeout ', + 'action when sandbox timeout is reached: pause or kill', + parseOnTimeout + ) + .option( + '--lifecycle.autoresume', + 'enable sandbox auto-resume, requires --lifecycle.ontimeout pause' + ) + .option('--timeout ', 'sandbox timeout in seconds', parseTimeout) + .alias(alias) + .action( + async ( + template: string | undefined, + opts: { + name?: string + path?: string + config?: string + detach?: boolean + 'lifecycle.ontimeout'?: SandboxLifecycle['onTimeout'] + 'lifecycle.autoresume'?: boolean + timeout?: number + } + ) => { + if (deprecated) { + console.warn( + `Warning: The '${name}' command is deprecated and will be removed in future releases. Please use 'e2b sandbox create' instead.` + ) + } + try { + const apiKey = ensureAPIKey() + let templateID = template + + const root = getRoot(opts.path) + const configPath = getConfigPath(root, opts.config) + + const config = fs.existsSync(configPath) + ? await loadConfig(configPath) + : undefined + const relativeConfigPath = path.relative(root, configPath) + + if (!templateID && config) { + console.log( + `Found sandbox template ${asFormattedSandboxTemplate( + { + templateID: config.template_id, + aliases: config.template_name + ? [config.template_name] + : undefined, + }, + relativeConfigPath + )}` + ) + templateID = config.template_id + } + + if (!templateID) { + templateID = 'base' + } + + const lifecycle = buildLifecycle( + opts['lifecycle.ontimeout'], + opts['lifecycle.autoresume'] + ) + const sandboxOpts = { + apiKey, + ...(lifecycle ? { lifecycle } : {}), + ...(opts.timeout !== undefined ? { timeoutMs: opts.timeout } : {}), + } + const sandbox = await e2b.Sandbox.create(templateID, sandboxOpts) + printDashboardSandboxInspectUrl(sandbox.sandboxId) + + if (!opts.detach) { + await connectSandbox({ + sandbox, + template: { templateID }, + timeoutMs: opts.timeout, + }) + } else { + console.log( + `Sandbox created with ID ${sandbox.sandboxId} using template ${templateID}` + ) + } + process.exit(0) + } catch (err: any) { + console.error(err) + process.exit(1) + } + } + ) +} + +function parseTimeout(timeoutRaw: string): number { + const timeoutSeconds = Number(timeoutRaw) + const timeoutMs = Math.floor(timeoutSeconds * 1000) + if ( + !Number.isFinite(timeoutSeconds) || + timeoutSeconds <= 0 || + timeoutMs < MIN_TIMEOUT_MS + ) { + throw new commander.InvalidArgumentError( + '--timeout must be at least 30 seconds' + ) + } + + return timeoutMs +} + +function parseOnTimeout(onTimeout: string): SandboxLifecycle['onTimeout'] { + if (onTimeout !== 'pause' && onTimeout !== 'kill') { + throw new commander.InvalidArgumentError( + '--lifecycle.ontimeout must be "pause" or "kill"' + ) + } + + return onTimeout +} + +function buildLifecycle( + onTimeout: SandboxLifecycle['onTimeout'] | undefined, + autoResume: boolean | undefined +): SandboxLifecycle | undefined { + if (!onTimeout && !autoResume) { + return undefined + } + + if (autoResume && onTimeout !== 'pause') { + throw new commander.InvalidArgumentError( + '--lifecycle.autoresume requires --lifecycle.ontimeout pause' + ) + } + + if (!onTimeout) { + throw new commander.InvalidArgumentError( + '--lifecycle.ontimeout is required when using --lifecycle.autoresume' + ) + } + + return { onTimeout, ...(autoResume ? { autoResume: true } : {}) } +} + +export async function connectSandbox({ + sandbox, + template, + timeoutMs, +}: { + sandbox: e2b.Sandbox + template: Pick + timeoutMs?: number +}) { + // keep-alive loop — track the in-flight promise so we can await it on shutdown + let pendingKeepAlive: Promise = Promise.resolve() + const keepAliveTimeoutMs = timeoutMs ?? 30_000 + const intervalId = setInterval(() => { + pendingKeepAlive = sandbox.setTimeout(keepAliveTimeoutMs) + }, 5_000) + + console.log( + `Terminal connecting to template ${asFormattedSandboxTemplate( + template + )} with sandbox ID ${asBold(`${sandbox.sandboxId}`)}` + ) + try { + await spawnConnectedTerminal(sandbox) + } finally { + clearInterval(intervalId) + await pendingKeepAlive.catch(() => {}) + await sandbox.setTimeout(timeoutMs ?? 1_000) + console.log( + `Closing terminal connection to template ${asFormattedSandboxTemplate( + template + )} with sandbox ID ${asBold(`${sandbox.sandboxId}`)}` + ) + } +} diff --git a/packages/cli/src/commands/sandbox/exec.ts b/packages/cli/src/commands/sandbox/exec.ts new file mode 100644 index 0000000..959ee56 --- /dev/null +++ b/packages/cli/src/commands/sandbox/exec.ts @@ -0,0 +1,218 @@ +/** + * Execute a command in a running sandbox. + */ + +import { Sandbox, CommandExitError, NotFoundError } from 'e2b' +import * as commander from 'commander' + +import { ensureAPIKey } from '../../api' +import { setupSignalHandlers } from 'src/utils/signal' +import { buildCommand, isPipedStdin, streamStdinChunks } from './exec_helpers' + +interface ExecOptions { + background?: boolean + cwd?: string + user?: string + env?: Record +} + +const NO_COMMAND_TIMEOUT = 0 +export const execCommand = new commander.Command('exec') + .description('execute a command in a running sandbox') + .argument('', 'sandbox ID to execute command in') + .argument('', 'command to execute') + .option('-b, --background', 'run in background and return immediately') + .option('-c, --cwd ', 'working directory') + .option('-u, --user ', 'run as specified user') + .option( + '-e, --env ', + 'set environment variable (repeatable)', + (value: string, previous: Record) => { + const [key, ...rest] = value.split('=') + if (key && rest.length > 0) { + previous[key] = rest.join('=') + } + return previous + }, + {} as Record + ) + .alias('ex') + .action( + async (sandboxID: string, commandParts: string[], opts: ExecOptions) => { + const hasPipedStdin = isPipedStdin() + + const command = buildCommand(commandParts) + try { + const apiKey = ensureAPIKey() + const sandbox = await Sandbox.connect(sandboxID, { apiKey }) + + if (hasPipedStdin && !sandbox.commands.supportsStdinClose) { + console.error( + 'e2b: Warning: Piped stdin is not supported by this sandbox version.\n' + + 'e2b: Rebuild your template to pick up the latest sandbox version.\n' + + 'e2b: Ignoring piped stdin.' + ) + } + + const canPipeStdin = + hasPipedStdin && sandbox.commands.supportsStdinClose + + if (opts.background) { + const handle = await sandbox.commands.run(command, { + background: true, + cwd: opts.cwd, + user: opts.user, + envs: opts.env, + timeoutMs: NO_COMMAND_TIMEOUT, + ...(canPipeStdin ? { stdin: true } : {}), + }) + + const removeSignalHandlers = setupSignalHandlers(async () => { + await handle.kill() + }) + + try { + if (canPipeStdin) { + await sendStdin(sandbox, handle.pid) + } + } finally { + removeSignalHandlers() + } + + console.error(handle.pid) + + await handle.disconnect() + + // We always exit with code 0 when running in background. + process.exit(0) + } + + const exitCode = await runCommand(sandbox, command, opts, canPipeStdin) + + process.exit(exitCode) + } catch (err: any) { + console.error(err) + process.exit(1) + } + } + ) + +async function runCommand( + sandbox: Sandbox, + command: string, + opts: ExecOptions, + openStdin: boolean +): Promise { + const handle = await sandbox.commands.run(command, { + background: true, + cwd: opts.cwd, + user: opts.user, + envs: opts.env, + timeoutMs: NO_COMMAND_TIMEOUT, + onStdout: async (data) => { + try { + process.stdout.write(data) + } catch (err: any) { + console.error(err) + await handle.kill() + } + }, + onStderr: async (data) => { + try { + process.stderr.write(data) + } catch (err: any) { + console.error(err) + await handle.kill() + } + }, + ...(openStdin ? { stdin: true } : {}), + }) + + const removeSignalHandlers = setupSignalHandlers(async () => { + // Kill the remote process - main loop handles exit code. + await handle.kill() + }) + + try { + if (openStdin) { + await sendStdin(sandbox, handle.pid) + } + + const result = await handle.wait() + + return result.exitCode + } catch (err) { + if (handle.error) { + console.error(handle.error) + } + + if (err instanceof CommandExitError) { + return err.exitCode + } + + // If exit code is not from the command we throw the error. + throw err + } finally { + removeSignalHandlers() + } +} + +async function sendStdin(sandbox: Sandbox, pid: number): Promise { + const chunkSizeBytes = 64 * 1024 + let processExited = false + + await streamStdinChunks( + process.stdin, + async (chunk) => { + if (processExited) { + return false + } + + try { + await sandbox.commands.sendStdin(pid, chunk) + } catch (err) { + if (err instanceof NotFoundError) { + processExited = true + console.error( + 'e2b: Remote command exited before stdin could be delivered.' + ) + return false + } + throw err + } + }, + chunkSizeBytes + ) + + if (processExited) { + return + } + + // Signal EOF so commands like cat/wc/grep terminate. + try { + await sandbox.commands.closeStdin(pid) + } catch (err) { + if (err instanceof NotFoundError) { + // Process already exited — EOF is moot. + return + } + + // Fail fast instead of leaving a command blocked on stdin forever. + await killProcessBestEffort(sandbox, pid) + throw err + } +} + +async function killProcessBestEffort( + sandbox: Sandbox, + pid: number +): Promise { + try { + await sandbox.commands.kill(pid) + } catch (killErr) { + console.error( + 'e2b: Failed to kill remote process after stdin EOF signaling failed.' + ) + console.error(killErr) + } +} diff --git a/packages/cli/src/commands/sandbox/exec_helpers.ts b/packages/cli/src/commands/sandbox/exec_helpers.ts new file mode 100644 index 0000000..7dc7cb7 --- /dev/null +++ b/packages/cli/src/commands/sandbox/exec_helpers.ts @@ -0,0 +1,121 @@ +import * as fs from 'fs' + +const SHELL_SAFE_RE = /^[A-Za-z0-9_@%+=:,./-]+$/ + +export const shellQuote = (arg: string): string => { + if (arg === '') { + return "''" + } + if (SHELL_SAFE_RE.test(arg)) { + return arg + } + const q = "'\"'\"'" + return `'${arg.replace(/'/g, q)}'` +} + +export const buildCommand = (commandParts: string[]): string => { + if (commandParts.length === 1) { + return commandParts[0] + } + + return commandParts.map(shellQuote).join(' ') +} + +type StatLike = { + isFIFO?: () => boolean + isFile?: () => boolean + isSocket?: () => boolean + isCharacterDevice?: () => boolean +} +type FsLike = { fstatSync: (fd: number) => StatLike } + +export const isPipedStdin = (fd = 0, fsModule: FsLike = fs as FsLike) => { + try { + const stdinStats = fsModule.fstatSync(fd) + // Treat any non-interactive stdin as "piped": FIFO pipes and file redirection (`< file`). + // Keep this conservative so normal terminal stdin doesn't get eagerly drained. + if (stdinStats.isCharacterDevice?.()) { + return false + } + return Boolean( + stdinStats.isFIFO?.() || stdinStats.isFile?.() || stdinStats.isSocket?.() + ) + } catch { + return false + } +} + +type ReadStdinOptions = { + fd?: number + fsModule?: FsLike + stream?: NodeJS.ReadableStream +} + +type StdinChunk = Uint8Array | Buffer | string + +export const readStdinIfPiped = async ( + options: ReadStdinOptions = {} +): Promise => { + const fd = options.fd ?? 0 + const fsModule = options.fsModule ?? (fs as FsLike) + if (!isPipedStdin(fd, fsModule)) { + return undefined + } + const stream = options.stream ?? process.stdin + return await readStdinFrom(stream) +} + +export const chunkBytesBySize = ( + data: Uint8Array, + maxBytes: number +): Uint8Array[] => { + if (maxBytes <= 0) { + throw new Error('maxBytes must be greater than 0') + } + + const chunks: Uint8Array[] = [] + for (let offset = 0; offset < data.length; offset += maxBytes) { + chunks.push(data.subarray(offset, offset + maxBytes)) + } + return chunks +} + +export async function streamStdinChunks( + stream: NodeJS.ReadableStream, + onChunk: (chunk: Uint8Array) => Promise | void | boolean, + maxBytes: number +): Promise { + if (maxBytes <= 0) { + throw new Error('maxBytes must be greater than 0') + } + + for await (const rawChunk of stream as AsyncIterable) { + const chunk = + typeof rawChunk === 'string' + ? Buffer.from(rawChunk) + : Buffer.from(rawChunk) + + if (chunk.byteLength === 0) { + continue + } + + const pieces = chunkBytesBySize(chunk, maxBytes) + for (const piece of pieces) { + const shouldContinue = await onChunk(piece) + if (shouldContinue === false) { + return + } + } + } +} + +export async function readStdinFrom( + stream: NodeJS.ReadableStream +): Promise { + return await new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))) + stream.on('end', () => resolve(Buffer.concat(chunks))) + stream.on('error', reject) + }) +} diff --git a/packages/cli/src/commands/sandbox/index.ts b/packages/cli/src/commands/sandbox/index.ts new file mode 100644 index 0000000..7f634c4 --- /dev/null +++ b/packages/cli/src/commands/sandbox/index.ts @@ -0,0 +1,27 @@ +import * as commander from 'commander' + +import { connectCommand } from './connect' +import { infoCommand } from './info' +import { listCommand } from './list' +import { killCommand } from './kill' +import { pauseCommand } from './pause' +import { resumeCommand } from './resume' +import { createCommand } from './create' +import { logsCommand } from './logs' +import { metricsCommand } from './metrics' +import { execCommand } from './exec' + +export const sandboxCommand = new commander.Command('sandbox') + .description('work with sandboxes') + .alias('sbx') + .addCommand(connectCommand) + .addCommand(infoCommand) + .addCommand(listCommand) + .addCommand(killCommand) + .addCommand(pauseCommand) + .addCommand(resumeCommand) + .addCommand(createCommand('create', 'cr', false)) + .addCommand(createCommand('spawn', 'sp', true), { hidden: true }) + .addCommand(logsCommand) + .addCommand(metricsCommand) + .addCommand(execCommand) diff --git a/packages/cli/src/commands/sandbox/info.ts b/packages/cli/src/commands/sandbox/info.ts new file mode 100644 index 0000000..c15cb08 --- /dev/null +++ b/packages/cli/src/commands/sandbox/info.ts @@ -0,0 +1,118 @@ +import * as commander from 'commander' +import { NotFoundError, Sandbox } from 'e2b' + +import { ensureAPIKey } from 'src/api' +import { asBold } from 'src/utils/format' + +const fieldLabels: Partial> = { + sandboxId: 'Sandbox ID', + templateId: 'Template ID', + name: 'Alias', + startedAt: 'Started at', + endAt: 'End at', + state: 'State', + cpuCount: 'vCPUs', + memoryMB: 'RAM MiB', + envdVersion: 'Envd version', + allowInternetAccess: 'Internet access', + lifecycle: 'Lifecycle', + network: 'Network', + sandboxDomain: 'Sandbox domain', + metadata: 'Metadata', +} + +const fieldOrder = [ + 'sandboxId', + 'templateId', + 'name', + 'state', + 'startedAt', + 'endAt', + 'cpuCount', + 'memoryMB', + 'envdVersion', + 'allowInternetAccess', + 'lifecycle', + 'network', + 'sandboxDomain', + 'metadata', +] + +export const infoCommand = new commander.Command('info') + .description('show information for a sandbox') + .argument( + '', + `show information for sandbox specified by ${asBold('')}` + ) + .alias('in') + .option('-f, --format ', 'output format, eg. json, pretty') + .action(async (sandboxID: string, options: { format?: string }) => { + try { + const format = options.format || 'pretty' + const apiKey = ensureAPIKey() + const info = await Sandbox.getInfo(sandboxID, { apiKey }) + + if (format === 'pretty') { + renderPrettyInfo(info as unknown as Record) + } else if (format === 'json') { + console.log(JSON.stringify(info, null, 2)) + } else { + console.error(`Unsupported output format: ${format}`) + process.exit(1) + } + } catch (err: any) { + if (err instanceof NotFoundError) { + console.error(`Sandbox ${asBold(sandboxID)} wasn't found`) + process.exit(1) + return + } + console.error(err) + process.exit(1) + } + }) + +function renderPrettyInfo(info: Record) { + console.log( + `\nSandbox info for ${asBold(String(info.sandboxId ?? 'unknown'))}:` + ) + + const orderedKeys = [ + ...fieldOrder.filter((key) => key in info), + ...Object.keys(info).filter((key) => !fieldOrder.includes(key)), + ] + + for (const key of orderedKeys) { + const value = info[key] + if (value === undefined) { + continue + } + + const label = fieldLabels[key] ?? key + const formattedValue = formatValue(value) + + if (formattedValue.includes('\n')) { + const indentedValue = formattedValue + .split('\n') + .map((line) => ` ${line}`) + .join('\n') + console.log(`${asBold(label)}:\n${indentedValue}`) + continue + } + + console.log(`${asBold(label)}: ${formattedValue}`) + } + + process.stdout.write('\n') +} + +function formatValue(value: unknown): string { + if (value instanceof Date) { + return value.toLocaleString() + } + + if (Array.isArray(value) || (typeof value === 'object' && value !== null)) { + return JSON.stringify(value, null, 2) + } + + return String(value) +} diff --git a/packages/cli/src/commands/sandbox/kill.ts b/packages/cli/src/commands/sandbox/kill.ts new file mode 100644 index 0000000..978b2f1 --- /dev/null +++ b/packages/cli/src/commands/sandbox/kill.ts @@ -0,0 +1,107 @@ +import * as commander from 'commander' + +import { ensureAPIKey } from 'src/api' +import { asBold } from 'src/utils/format' +import * as e2b from 'e2b' +import { Sandbox, components } from 'e2b' +import { parseMetadata } from './utils' + +async function killSandbox(sandboxID: string, apiKey: string) { + const killed = await e2b.Sandbox.kill(sandboxID, { apiKey }) + if (killed) { + console.log(`Sandbox ${asBold(sandboxID)} has been killed`) + } else { + console.error(`Sandbox ${asBold(sandboxID)} wasn't found`) + } +} + +export const killCommand = new commander.Command('kill') + .description('kill sandbox') + .argument( + '[sandboxIDs...]', + `kill the sandboxes specified by ${asBold('[sandboxIDs...]')}` + ) + .alias('kl') + .option('-a, --all', 'kill all sandboxes') + .option( + '-s, --state ', + 'when used with -a/--all flag, filter by state, eg. running, paused. Defaults to running', + (value) => value.split(',') + ) + .option( + '-m, --metadata ', + 'when used with -a/--all flag, filter by metadata, eg. key1=value1' + ) + .action( + async ( + sandboxIDs: string[], + { + all, + state, + metadata, + }: { + all: boolean + state: components['schemas']['SandboxState'][] + metadata: string + } + ) => { + try { + const apiKey = ensureAPIKey() + const sandboxesState = state || ['running'] + const sandboxesMetadata = parseMetadata(metadata) + + if ((!sandboxIDs || sandboxIDs.length === 0) && !all) { + console.error( + `You need to specify ${asBold('[sandboxIDs...]')} or use ${asBold( + '-a/--all' + )} flag` + ) + process.exit(1) + } + + if (all && sandboxIDs && sandboxIDs.length > 0) { + console.error( + `You cannot use ${asBold( + '-a/--all' + )} flag while specifying ${asBold('[sandboxIDs...]')}` + ) + process.exit(1) + } + + if (all) { + let total = 0 + const iterator = Sandbox.list({ + apiKey, + query: { + state: sandboxesState, + metadata: sandboxesMetadata, + }, + }) + + while (iterator.hasNext) { + const sandboxes = await iterator.nextItems() + total += sandboxes.length + + await Promise.all( + sandboxes.map((sandbox) => killSandbox(sandbox.sandboxId, apiKey)) + ) + } + + if (total === 0) { + console.log('No running sandboxes') + } else { + console.log(`Killed ${total} running sandboxes`) + } + + process.exit(0) + } else { + await Promise.all( + sandboxIDs.map((sandboxID) => killSandbox(sandboxID, apiKey)) + ) + } + } catch (err: any) { + console.error(err) + process.exit(1) + } + } + ) diff --git a/packages/cli/src/commands/sandbox/list.ts b/packages/cli/src/commands/sandbox/list.ts new file mode 100644 index 0000000..6e779f3 --- /dev/null +++ b/packages/cli/src/commands/sandbox/list.ts @@ -0,0 +1,178 @@ +import * as tablePrinter from 'console-table-printer' +import * as commander from 'commander' +import { components, Sandbox, SandboxInfo } from 'e2b' + +import { ensureAPIKey } from 'src/api' +import { parseMetadata } from './utils' + +const DEFAULT_LIMIT = 1000 +const PAGE_LIMIT = 100 + +function getStateTitle(state?: components['schemas']['SandboxState'][]) { + if (state?.length === 1) { + if (state?.includes('running')) return 'Running sandboxes' + if (state?.includes('paused')) return 'Paused sandboxes' + } + return 'Sandboxes' +} + +export const listCommand = new commander.Command('list') + .description('list all sandboxes, by default it list only running ones') + .alias('ls') + .option( + '-s, --state ', + 'filter by state, eg. running, paused. Defaults to running', + (value) => value.split(',') + ) + .option('-m, --metadata ', 'filter by metadata, eg. key1=value1') + .option( + '-l, --limit ', + `limit the number of sandboxes returned (default: ${DEFAULT_LIMIT}, 0 for no limit)`, + (value) => parseInt(value) + ) + .option('-f, --format ', 'output format, eg. json, pretty') + .action(async (options) => { + try { + const state = options.state || ['running'] + const format = options.format || 'pretty' + const limit = + options.limit === 0 ? undefined : (options.limit ?? DEFAULT_LIMIT) + const { sandboxes, hasMore } = await listSandboxes({ + limit, + state, + metadataRaw: options.metadata, + }) + + if (format === 'pretty') { + renderTable(sandboxes, state) + if (hasMore) { + console.log( + `Showing first ${limit} sandboxes. Use --limit to change.` + ) + } + } else if (format === 'json') { + console.log(JSON.stringify(sandboxes, null, 2)) + } else { + console.error(`Unsupported output format: ${format}`) + process.exit(1) + } + } catch (err: any) { + console.error(err) + process.exit(1) + } + }) + +function renderTable( + sandboxes: SandboxInfo[], + state: components['schemas']['SandboxState'][] +) { + if (!sandboxes?.length) { + console.log('No sandboxes found') + return + } + + const table = new tablePrinter.Table({ + title: getStateTitle(state), + columns: [ + { name: 'sandboxId', alignment: 'left', title: 'Sandbox ID' }, + { + name: 'templateId', + alignment: 'left', + title: 'Template ID', + maxLen: 20, + }, + { name: 'name', alignment: 'left', title: 'Alias' }, + { name: 'startedAt', alignment: 'left', title: 'Started at' }, + { name: 'endAt', alignment: 'left', title: 'End at' }, + { name: 'state', alignment: 'left', title: 'State' }, + { name: 'cpuCount', alignment: 'left', title: 'vCPUs' }, + { name: 'memoryMB', alignment: 'left', title: 'RAM MiB' }, + { name: 'envdVersion', alignment: 'left', title: 'Envd version' }, + { name: 'metadata', alignment: 'left', title: 'Metadata' }, + ], + disabledColumns: ['clientID'], + rows: sandboxes + .map((sandbox) => ({ + ...sandbox, + startedAt: new Date(sandbox.startedAt).toLocaleString(), + endAt: new Date(sandbox.endAt).toLocaleString(), + state: sandbox.state.charAt(0).toUpperCase() + sandbox.state.slice(1), // capitalize + metadata: JSON.stringify(sandbox.metadata), + })) + .sort( + (a, b) => + a.startedAt.localeCompare(b.startedAt) || + a.sandboxId.localeCompare(b.sandboxId) + ), + style: { + headerTop: { + left: '', + right: '', + mid: '', + other: '', + }, + headerBottom: { + left: '', + right: '', + mid: '', + other: '', + }, + tableBottom: { + left: '', + right: '', + mid: '', + other: '', + }, + vertical: '', + }, + colorMap: { + orange: '\x1b[38;5;216m', + }, + }) + table.printTable() + + process.stdout.write('\n') +} + +type ListSandboxesOptions = { + limit?: number + state?: components['schemas']['SandboxState'][] + metadataRaw?: string +} + +type ListSandboxesResult = { + sandboxes: SandboxInfo[] + hasMore: boolean +} + +export async function listSandboxes({ + limit, + state, + metadataRaw, +}: ListSandboxesOptions = {}): Promise { + const apiKey = ensureAPIKey() + const metadata = parseMetadata(metadataRaw) + + let pageLimit = limit + if (!limit || limit > PAGE_LIMIT) { + pageLimit = PAGE_LIMIT + } + + const sandboxes: SandboxInfo[] = [] + const iterator = Sandbox.list({ + apiKey: apiKey, + limit: pageLimit, + query: { state, metadata }, + }) + + while (iterator.hasNext && (!limit || sandboxes.length < limit)) { + const batch = await iterator.nextItems() + sandboxes.push(...batch) + } + + return { + sandboxes: limit ? sandboxes.slice(0, limit) : sandboxes, + // We can't change the page size during iteration, so we may have to check if we have more sandboxes than the limit + hasMore: iterator.hasNext || (limit ? sandboxes.length > limit : false), + } +} diff --git a/packages/cli/src/commands/sandbox/logs.ts b/packages/cli/src/commands/sandbox/logs.ts new file mode 100644 index 0000000..caf78a5 --- /dev/null +++ b/packages/cli/src/commands/sandbox/logs.ts @@ -0,0 +1,265 @@ +import * as commander from 'commander' +import * as e2b from 'e2b' +import * as util from 'util' +import * as chalk from 'chalk' + +import { client, connectionConfig } from 'src/api' +import { asBold, asTimestamp, withUnderline } from 'src/utils/format' +import { wait } from 'src/utils/wait' +import { handleE2BRequestError } from '../../utils/errors' +import { waitForSandboxEnd, formatEnum, Format, isRunning } from './utils' + +enum LogLevel { + DEBUG = 'DEBUG', + INFO = 'INFO', + WARN = 'WARN', + ERROR = 'ERROR', +} + +function isLevelIncluded(level: LogLevel, allowedLevel?: LogLevel) { + if (!allowedLevel) { + return true + } + + switch (allowedLevel) { + case LogLevel.DEBUG: + return true + case LogLevel.INFO: + return ( + level === LogLevel.INFO || + level === LogLevel.WARN || + level === LogLevel.ERROR + ) + case LogLevel.WARN: + return level === LogLevel.WARN || level === LogLevel.ERROR + case LogLevel.ERROR: + return level === LogLevel.ERROR + } +} + +function cleanLogger(logger?: string) { + if (!logger) { + return '' + } + + return logger.replaceAll('Svc', '') +} + +export const logsCommand = new commander.Command('logs') + .description('show logs for sandbox') + .argument( + '', + `show logs for sandbox specified by ${asBold('')}` + ) + .alias('lg') + .option( + '--level ', + `filter logs by level (${formatEnum( + LogLevel + )}). The logs with the higher levels will be also shown.`, + LogLevel.INFO + ) + .option('-f, --follow', 'keep streaming logs until the sandbox is closed') + .option( + '--format ', + `specify format for printing logs (${formatEnum(Format)})`, + Format.PRETTY + ) + .option( + '--loggers [loggers]', + 'filter logs by loggers. Specify multiple loggers by separating them with a comma.', + (val: string) => val.split(',') + ) + .action( + async ( + sandboxID: string, + opts?: { + level: string + follow: boolean + format: Format + loggers?: string[] + } + ) => { + try { + const level = opts?.level.toUpperCase() as LogLevel | undefined + if (level && !Object.values(LogLevel).includes(level)) { + throw new Error(`Invalid log level: ${level}`) + } + + const format = opts?.format.toLowerCase() as Format | undefined + if (format && !Object.values(Format).includes(format)) { + throw new Error(`Invalid log format: ${format}`) + } + + const getIsRunning = opts?.follow + ? waitForSandboxEnd(sandboxID) + : () => false + + let start: number | undefined + let isFirstRun = true + let firstLogsPrinted = false + + if (format === Format.PRETTY) { + console.log(`\nLogs for sandbox ${asBold(sandboxID)}:`) + } + + do { + const logs = await listSandboxLogs({ sandboxID, start }) + + if (logs.length !== 0 && firstLogsPrinted === false) { + firstLogsPrinted = true + process.stdout.write('\n') + } + + for (const log of logs) { + printLog( + log.timestamp, + log.line, + level, + format, + opts?.loggers ?? undefined + ) + } + + if (!opts?.follow) break + + const isSandboxRunning = await isRunning(sandboxID) + + if (!isSandboxRunning && logs.length === 0 && isFirstRun) { + if (format === Format.PRETTY) { + console.log( + `\nStopped printing logs — sandbox ${withUnderline( + 'not found' + )}` + ) + } + break + } + + if (!isSandboxRunning) { + if (format === Format.PRETTY) { + console.log( + `\nStopped printing logs — sandbox is ${withUnderline( + 'closed' + )}` + ) + } + break + } + + const lastLog = logs.length > 0 ? logs[logs.length - 1] : undefined + if (lastLog) { + // TODO: Use the timestamp from the last log instead of the current time? + start = new Date(lastLog.timestamp).getTime() + 1 + } + + await wait(400) + isFirstRun = false + } while (getIsRunning() && opts?.follow) + } catch (err: any) { + console.error(err) + process.exit(1) + } + } + ) + +function printLog( + timestamp: string, + line: string, + allowedLevel: LogLevel | undefined, + format: Format | undefined, + allowedLoggers?: string[] | undefined +) { + const log = JSON.parse(line) + let level = log['level'].toUpperCase() + + log.logger = cleanLogger(log.logger) + + // Check if the current logger startsWith any of the allowed loggers. If there are no specified loggers, print logs from all loggers. + if ( + allowedLoggers !== undefined && + Array.isArray(allowedLoggers) && + !allowedLoggers.some((allowedLogger) => + log.logger.startsWith(allowedLogger) + ) + ) { + return + } + + if (!isLevelIncluded(level, allowedLevel)) { + return + } + + switch (level) { + case LogLevel.DEBUG: + level = chalk.default.black(chalk.default.bgWhite(level)) + break + case LogLevel.INFO: + level = chalk.default.black(chalk.default.bgGreen(level) + ' ') + break + case LogLevel.WARN: + level = chalk.default.black(chalk.default.bgYellow(level) + ' ') + break + case LogLevel.ERROR: + level = chalk.default.white(chalk.default.bgRed(level)) + break + } + + delete log['traceID'] + delete log['instanceID'] + delete log['source_type'] + delete log['teamID'] + delete log['source'] + delete log['service'] + delete log['envID'] + delete log['sandboxID'] + + if (format === Format.JSON) { + console.log( + JSON.stringify({ + timestamp: new Date(timestamp).toISOString(), + level, + ...log, + }) + ) + } else { + const time = `[${new Date(timestamp).toISOString().replace(/T/, ' ')}]` + delete log['level'] + console.log( + `${asTimestamp(time)} ${level} ` + + util.inspect(log, { + colors: true, + depth: null, + maxArrayLength: Infinity, + sorted: true, + compact: true, + breakLength: Infinity, + }) + ) + } +} + +export async function listSandboxLogs({ + sandboxID, + start, +}: { + sandboxID: string + start?: number +}): Promise { + const signal = connectionConfig.getSignal() + const res = await client.api.GET('/sandboxes/{sandboxID}/logs', { + signal, + params: { + path: { + sandboxID, + }, + query: { + start, + }, + }, + }) + + handleE2BRequestError(res, 'Error while getting sandbox logs') + + return res.data.logs +} diff --git a/packages/cli/src/commands/sandbox/metrics.ts b/packages/cli/src/commands/sandbox/metrics.ts new file mode 100644 index 0000000..1bdc659 --- /dev/null +++ b/packages/cli/src/commands/sandbox/metrics.ts @@ -0,0 +1,148 @@ +import * as chalk from 'chalk' +import * as commander from 'commander' + +import { asBold, asTimestamp, withUnderline } from 'src/utils/format' +import { wait } from 'src/utils/wait' +import { formatEnum, Format, isRunning } from './utils' +import { Sandbox } from 'e2b' +import { ensureAPIKey } from '../../api' + +export const metricsCommand = new commander.Command('metrics') + .description('show metrics for sandbox') + .argument( + '', + `show metrics for sandbox specified by ${asBold('')}` + ) + .alias('mt') + .option('-f, --follow', 'keep streaming metrics until the sandbox is closed') + .option( + '--format ', + `specify format for printing metrics (${formatEnum(Format)})`, + Format.PRETTY + ) + .action( + async ( + sandboxID: string, + opts?: { + follow: boolean + format: Format + } + ) => { + try { + const format = opts?.format.toLowerCase() as Format | undefined + if (format && !Object.values(Format).includes(format)) { + throw new Error(`Invalid log format: ${format}`) + } + + let start: Date | undefined + let isFirstRun = true + let firstMetricsPrinted = false + + if (format === Format.PRETTY) { + console.log(`\nMetrics for sandbox ${asBold(sandboxID)}:`) + } + + const apiKey = ensureAPIKey() + const isRunningPromise = isRunning(sandboxID) + + do { + const metrics = await Sandbox.getMetrics(sandboxID, { start, apiKey }) + + if (metrics.length !== 0 && !firstMetricsPrinted) { + firstMetricsPrinted = true + process.stdout.write('\n') + } + + for (const metric of metrics) { + if (start && metric.timestamp <= start) { + // Skip the metric if it has the same timestamp as the last one + continue + } + start = metric.timestamp + + printMetric(metric.timestamp, JSON.stringify(metric), format) + } + + const isRunning = await isRunningPromise + + if (!isRunning && metrics.length === 0 && isFirstRun) { + if (format === Format.PRETTY) { + console.log( + `\nStopped printing metrics — sandbox ${withUnderline( + 'not found' + )}` + ) + } + break + } + + if (!isRunning) { + if (format === Format.PRETTY) { + console.log( + `\nStopped printing metrics — sandbox is ${withUnderline( + 'closed' + )}` + ) + } + break + } + + await wait(400) + isFirstRun = false + } while (opts?.follow) + } catch (err: any) { + console.error(err) + process.exit(1) + } + } + ) + +function printMetric( + timestamp: Date, + line: string, + format: Format | undefined +) { + const metric = JSON.parse(line) + const level = chalk.default.green() + + if (format === Format.JSON) { + console.log( + JSON.stringify({ + timestamp: timestamp.toISOString(), + ...metric, + }) + ) + } else { + const time = `[${timestamp + .toISOString() + .replace(/\.\d{3}Z/, 'Z') + .replace(/T/, ' ')}]` + delete metric['timestamp'] + const multipleCores = metric.cpuCount > 1 + metric.cpuCount += 0 + console.log( + `${asTimestamp(time)} ${level} ` + + asBold('CPU') + + `: ${metric.cpuUsedPct.toString().padStart(5)}% / ${metric.cpuCount + .toString() + .padStart(2)} Core${multipleCores && 's'} | ` + + asBold('Memory') + + `: ${toMB(metric.memUsed).toFixed(0).padStart(5)} / ${toMB( + metric.memTotal + ) + .toFixed(0) + .padEnd(5)} MiB | ` + + asBold('Disk') + + `: ${toMB(metric.diskUsed).toFixed(0).padStart(5)} / ${toMB( + metric.diskTotal + ) + .toFixed(0) + .padEnd(5)} MiB` + ) + } +} + +// we can't use bite shift here because shift is 32 bit operation and disk sizes can be greater than 2^32 +function toMB(bytes: number): number { + return bytes / 1024 / 1024 +} diff --git a/packages/cli/src/commands/sandbox/pause.ts b/packages/cli/src/commands/sandbox/pause.ts new file mode 100644 index 0000000..0c18721 --- /dev/null +++ b/packages/cli/src/commands/sandbox/pause.ts @@ -0,0 +1,40 @@ +import * as commander from 'commander' + +import { ensureAPIKey } from 'src/api' +import { asBold } from 'src/utils/format' +import * as e2b from 'e2b' +import { NotFoundError } from 'e2b' + +async function pauseSandbox(sandboxID: string, apiKey: string) { + try { + const paused = await e2b.Sandbox.betaPause(sandboxID, { apiKey }) + if (paused) { + console.log(`Sandbox ${asBold(sandboxID)} has been paused`) + } else { + console.log(`Sandbox ${asBold(sandboxID)} is already paused`) + } + } catch (err: unknown) { + if (err instanceof NotFoundError) { + console.error(`Sandbox ${asBold(sandboxID)} wasn't found`) + process.exit(1) + } + throw err + } +} + +export const pauseCommand = new commander.Command('pause') + .description('pause sandbox') + .argument( + '', + `pause the sandbox specified by ${asBold('')}` + ) + .alias('ps') + .action(async (sandboxID: string) => { + try { + const apiKey = ensureAPIKey() + await pauseSandbox(sandboxID, apiKey) + } catch (err: unknown) { + console.error(err) + process.exit(1) + } + }) diff --git a/packages/cli/src/commands/sandbox/resume.ts b/packages/cli/src/commands/sandbox/resume.ts new file mode 100644 index 0000000..60811f8 --- /dev/null +++ b/packages/cli/src/commands/sandbox/resume.ts @@ -0,0 +1,36 @@ +import * as commander from 'commander' + +import { ensureAPIKey } from 'src/api' +import { asBold } from 'src/utils/format' +import * as e2b from 'e2b' +import { NotFoundError } from 'e2b' + +async function resumeSandbox(sandboxID: string, apiKey: string) { + try { + await e2b.Sandbox.connect(sandboxID, { apiKey }) + console.log(`Sandbox ${asBold(sandboxID)} has been resumed`) + } catch (err: unknown) { + if (err instanceof NotFoundError) { + console.error(`Sandbox ${asBold(sandboxID)} wasn't found`) + process.exit(1) + } + throw err + } +} + +export const resumeCommand = new commander.Command('resume') + .description('resume paused sandbox') + .argument( + '', + `resume the sandbox specified by ${asBold('')}` + ) + .alias('rs') + .action(async (sandboxID: string) => { + try { + const apiKey = ensureAPIKey() + await resumeSandbox(sandboxID, apiKey) + } catch (err: unknown) { + console.error(err) + process.exit(1) + } + }) diff --git a/packages/cli/src/commands/sandbox/utils.ts b/packages/cli/src/commands/sandbox/utils.ts new file mode 100644 index 0000000..9d2117b --- /dev/null +++ b/packages/cli/src/commands/sandbox/utils.ts @@ -0,0 +1,77 @@ +import { wait } from '../../utils/wait' +import { asBold } from '../../utils/format' +import { Sandbox } from 'e2b' +import { ensureAPIKey } from 'src/api' + +export function formatEnum(e: { [key: string]: string }) { + return Object.values(e) + .map((level) => asBold(level)) + .join(', ') +} + +export enum Format { + JSON = 'json', + PRETTY = 'pretty', +} + +const maxRuntime = 24 * 60 * 60 * 1000 // 24 hours in milliseconds + +export function waitForSandboxEnd(sandboxID: string) { + let running = true + + async function monitor() { + const startTime = new Date().getTime() + + // eslint-disable-next-line no-constant-condition + while (true) { + const currentTime = new Date().getTime() + const elapsedTime = currentTime - startTime // Time elapsed in milliseconds + + // Check if 24 hours (in milliseconds) have passed + if (elapsedTime >= maxRuntime) { + break + } + + running = await isRunning(sandboxID) + if (!running) { + break + } + + await wait(5000) + } + } + + monitor() + + return () => running +} + +export async function isRunning(sandboxID: string) { + try { + const apiKey = ensureAPIKey() + const info = await Sandbox.getInfo(sandboxID, { + apiKey, + }) + return info.state === 'running' + } catch (err) { + console.error(`Failed to check sandbox status: ${err}`) + return false + } +} + +export function parseMetadata(metadataRaw?: string) { + let metadata: Record | undefined = undefined + if (metadataRaw && metadataRaw.length > 0) { + const parsedMetadata: Record = {} + metadataRaw.split(',').map((pair: string) => { + const [key, value] = pair.split('=') + if (key && value) { + parsedMetadata[key.trim()] = value.trim() + } + }) + + metadata = parsedMetadata + } + + return metadata +} diff --git a/packages/cli/src/commands/template/build.ts b/packages/cli/src/commands/template/build.ts new file mode 100644 index 0000000..3ce2c0c --- /dev/null +++ b/packages/cli/src/commands/template/build.ts @@ -0,0 +1,37 @@ +import * as boxen from 'boxen' +import * as commander from 'commander' +import { asBold, asPrimary } from '../../utils/format' + +export const buildCommand = new commander.Command('build') + .description('Deprecated: use `e2b template create` instead.') + .argument('[template]', 'unused') + .allowUnknownOption(true) + .alias('bd') + .action(async () => { + const deprecationMessage = `${asBold('DEPRECATION WARNING')} + +This is the v1 build system which is now deprecated. +Please migrate to the new build system v2. + +Migration guide: ${asPrimary('https://e2b.dev/docs/template/migration-v2')}` + + const deprecationWarning = boxen.default(deprecationMessage, { + padding: { + bottom: 0, + top: 0, + left: 2, + right: 2, + }, + margin: { + top: 1, + bottom: 1, + left: 0, + right: 0, + }, + borderColor: 'yellow', + borderStyle: 'round', + }) + + console.log(deprecationWarning) + process.exit(1) + }) diff --git a/packages/cli/src/commands/template/create.ts b/packages/cli/src/commands/template/create.ts new file mode 100644 index 0000000..a512f85 --- /dev/null +++ b/packages/cli/src/commands/template/create.ts @@ -0,0 +1,212 @@ +import * as boxen from 'boxen' +import * as commander from 'commander' +import { defaultBuildLogger, Template, TemplateClass } from 'e2b' +import { connectionConfig, ensureAPIKey } from 'src/api' +import { + defaultDockerfileName, + fallbackDockerfileName, +} from 'src/docker/constants' +import { parsePositiveInt, pathOption } from 'src/options' +import { validateTemplateName } from 'src/utils/templateName' +import { getRoot } from 'src/utils/filesystem' +import { + asFormattedSandboxTemplate, + asLocal, + asLocalRelative, + asPrimary, + asPython, + asTypescript, + withDelimiter, +} from '../../utils/format' +import { getDockerfile } from './dockerfile' + +export const createCommand = new commander.Command('create') + .description( + 'build Dockerfile as a Sandbox template. This command reads a Dockerfile and builds it directly.' + ) + .argument( + '', + 'template name to create or rebuild. The template name must be lowercase and contain only letters, numbers, dashes and underscores.' + ) + .addOption(pathOption) + .option( + '-d, --dockerfile ', + `specify path to Dockerfile. By default E2B tries to find ${asLocal( + defaultDockerfileName + )} or ${asLocal(fallbackDockerfileName)} in root directory.` + ) + .option( + '-c, --cmd ', + 'specify command that will be executed when the sandbox is started.' + ) + .option( + '--ready-cmd ', + 'specify command that will need to exit 0 for the template to be ready.' + ) + .option( + '--cpu-count ', + 'specify the number of CPUs that will be used to run the sandbox. The default value is 2.', + parsePositiveInt('CPU count') + ) + .option( + '--memory-mb ', + 'specify the amount of memory in megabytes that will be used to run the sandbox. Must be an even number. The default value is 512.', + parsePositiveInt('Memory in megabytes') + ) + .option('--no-cache', 'skip cache when building the template.') + .alias('ct') + .action( + async ( + templateName: string, + opts: { + path?: string + dockerfile?: string + cmd?: string + readyCmd?: string + cpuCount?: number + memoryMb?: number + noCache?: boolean + } + ) => { + try { + process.stdout.write('\n') + + // Validate and normalize template name + try { + templateName = validateTemplateName(templateName) + } catch (err) { + console.error( + `Template name ${asLocal(templateName)} is not valid. ${ + err instanceof Error ? err.message : String(err) + }` + ) + process.exit(1) + } + + // Validate memory + if (opts.memoryMb && opts.memoryMb % 2 !== 0) { + console.error( + `The memory in megabytes must be an even number. You provided ${asLocal( + opts.memoryMb.toFixed(0) + )}.` + ) + process.exit(1) + } + + const root = getRoot(opts.path) + + // Use options directly + const dockerfile = opts.dockerfile + const startCmd = opts.cmd + const readyCmd = opts.readyCmd + const cpuCount = opts.cpuCount + const memoryMB = opts.memoryMb + + // Get Dockerfile content + const { dockerfileContent, dockerfileRelativePath } = getDockerfile( + root, + dockerfile + ) + + console.log( + `Found ${asLocalRelative( + dockerfileRelativePath + )} that will be used to build the sandbox template.` + ) + + // Initialize template builder with file context and parse Dockerfile + const baseTemplate = Template({ + fileContextPath: root, + }).fromDockerfile(dockerfileContent) + + // Apply start/ready commands if provided + let finalTemplate: TemplateClass = baseTemplate + if (startCmd && readyCmd) { + finalTemplate = baseTemplate.setStartCmd(startCmd, readyCmd) + } else if (readyCmd) { + finalTemplate = baseTemplate.setReadyCmd(readyCmd) + } else if (startCmd) { + console.error('Both start and ready commands must be provided.') + process.exit(1) + } + + console.log('\nBuilding sandbox template...\n') + + // Prepare API credentials for SDK + const apiKey = ensureAPIKey() + const domain = connectionConfig.domain + + // Build the template using SDK + try { + await Template.build(finalTemplate, { + alias: templateName, + cpuCount: cpuCount, + memoryMB: memoryMB, + skipCache: opts.noCache, + apiKey: apiKey, + domain: domain, + onBuildLogs: defaultBuildLogger(), + }) + } catch (error) { + console.error('\n❌ Template build failed.') + if (error instanceof Error) { + console.error('Error:', error.message) + } + process.exit(1) + } + + // Display success message with examples + const pythonExample = asPython(`from e2b import Sandbox, AsyncSandbox + +# Create sync sandbox +sandbox = Sandbox.create("${templateName}") + +# Create async sandbox +sandbox = await AsyncSandbox.create("${templateName}")`) + + const typescriptExample = asTypescript(`import { Sandbox } from 'e2b' + +// Create sandbox +const sandbox = await Sandbox.create('${templateName}')`) + + const examplesMessage = `You can now use the template to create custom sandboxes.\nLearn more on ${asPrimary( + 'https://e2b.dev/docs' + )}` + + const exampleHeader = boxen.default(examplesMessage, { + padding: { + bottom: 1, + top: 1, + left: 2, + right: 2, + }, + margin: { + top: 1, + bottom: 1, + left: 0, + right: 0, + }, + fullscreen(width) { + return [width, 0] + }, + float: 'left', + }) + + const exampleUsage = `${withDelimiter( + pythonExample, + 'Python SDK' + )}\n${withDelimiter(typescriptExample, 'JS SDK', true)}` + + console.log( + `\n✅ Building sandbox template ${asFormattedSandboxTemplate({ + templateID: templateName, + })} finished.\n${exampleHeader}\n${exampleUsage}\n` + ) + + process.exit(0) + } catch (err: any) { + console.error(err) + process.exit(1) + } + } + ) diff --git a/packages/cli/src/commands/template/delete.ts b/packages/cli/src/commands/template/delete.ts new file mode 100644 index 0000000..0ba4ada --- /dev/null +++ b/packages/cli/src/commands/template/delete.ts @@ -0,0 +1,187 @@ +import * as commander from 'commander' +import * as chalk from 'chalk' +import * as fs from 'fs' + +import { + asBold, + asFormattedError, + asFormattedSandboxTemplate, + asLocal, + asLocalRelative, +} from 'src/utils/format' +import { + configOption, + pathOption, + selectMultipleOption, + teamOption, +} from 'src/options' +import { + E2BConfig, + configName, + deleteConfig, + getConfigPath, + loadConfig, +} from 'src/config' +import { getRoot } from 'src/utils/filesystem' +import { listSandboxTemplates } from './list' +import { getPromptTemplates } from 'src/utils/templatePrompt' +import { confirm } from 'src/utils/confirm' +import { client, resolveTeamId } from 'src/api' +import { handleE2BRequestError } from '../../utils/errors' + +async function deleteTemplate(templateID: string) { + const res = await client.api.DELETE('/templates/{templateID}', { + params: { + path: { + templateID, + }, + }, + }) + + handleE2BRequestError(res, 'Error deleting sandbox template') + return +} + +export const deleteCommand = new commander.Command('delete') + .description(`delete sandbox template and ${asLocal(configName)} config`) + .argument( + '[template]', + `specify ${asBold('[template]')} to delete it. If you dont specify ${asBold( + '[template]' + )} the command will try to delete sandbox template defined by ${asLocal( + 'e2b.toml' + )}.` + ) + .addOption(pathOption) + .addOption(configOption) + .addOption(selectMultipleOption) + .addOption(teamOption) + .alias('dl') + .option('-y, --yes', 'skip manual delete confirmation') + .action( + async ( + template, + opts: { + path?: string + config?: string + yes?: boolean + select?: boolean + team?: string + } + ) => { + try { + let teamId = opts.team + + const root = getRoot(opts.path) + + const templates: (Pick & { + configPath?: string + })[] = [] + + if (template) { + templates.push({ + template_id: template, + }) + } else if (opts.select) { + teamId = resolveTeamId(teamId) + + const allTemplates = await listSandboxTemplates({ + teamID: teamId, + }) + + const selectedTemplates = await getPromptTemplates( + allTemplates, + 'Select sandbox templates to delete' + ) + templates.push( + ...selectedTemplates.map((e) => ({ + template_id: e.templateID, + ...e, + })) + ) + + if (!templates || templates.length === 0) { + console.log('No sandbox templates selected') + return + } + } else { + const configPath = getConfigPath(root, opts.config) + const config = fs.existsSync(configPath) + ? await loadConfig(configPath) + : undefined + + if (!config) { + console.log( + `No ${asLocal(configName)} found in ${asLocalRelative( + root + )}. Specify sandbox template with ${asBold( + '[template]' + )} argument or use interactive mode with ${asBold('-s')} flag.` + ) + return + } + + templates.push({ + ...config, + configPath, + }) + } + + if (!templates || templates.length === 0) { + console.log( + `No sandbox templates selected. Specify sandbox template with ${asBold( + '[template]' + )} argument or use interactive mode with ${asBold('-s')} flag.` + ) + return + } + + console.log( + chalk.default.red( + chalk.default.underline('\nSandbox templates to delete') + ) + ) + templates.forEach((e) => + console.log( + asFormattedSandboxTemplate( + { ...e, templateID: e.template_id }, + e.configPath + ) + ) + ) + process.stdout.write('\n') + + if (!opts.yes) { + const confirmed = await confirm( + `Do you really want to delete ${ + templates.length === 1 ? 'this template' : 'these templates' + }?` + ) + + if (!confirmed) { + console.log('Canceled') + return + } + } + + await Promise.all( + templates.map(async (e) => { + console.log( + `- Deleting sandbox template ${asFormattedSandboxTemplate( + { ...e, templateID: e.template_id }, + e.configPath + )}` + ) + await deleteTemplate(e.template_id) + if (e.configPath) { + await deleteConfig(e.configPath) + } + }) + ) + process.stdout.write('\n') + } catch (err: any) { + console.error(asFormattedError(err.message)) + process.exit(1) + } + } + ) diff --git a/packages/cli/src/commands/template/dockerfile.ts b/packages/cli/src/commands/template/dockerfile.ts new file mode 100644 index 0000000..635d98e --- /dev/null +++ b/packages/cli/src/commands/template/dockerfile.ts @@ -0,0 +1,71 @@ +import * as fs from 'fs' +import * as path from 'path' +import { + defaultDockerfileName, + fallbackDockerfileName, +} from 'src/docker/constants' +import { asBold, asLocalRelative } from '../../utils/format' + +function loadFile(filePath: string) { + if (!fs.existsSync(filePath)) { + return undefined + } + + return fs.readFileSync(filePath, 'utf-8') +} + +export function getDockerfile(root: string, file?: string) { + if (file) { + const dockerfilePath = path.join(root, file) + const dockerfileContent = loadFile(dockerfilePath) + const dockerfileRelativePath = path.relative(root, dockerfilePath) + + if (dockerfileContent === undefined) { + throw new Error( + `No ${asLocalRelative( + dockerfileRelativePath + )} found in the root directory.` + ) + } + + return { + dockerfilePath, + dockerfileContent, + dockerfileRelativePath, + } + } + + let dockerfilePath = path.join(root, defaultDockerfileName) + let dockerfileContent = loadFile(dockerfilePath) + const defaultDockerfileRelativePath = path.relative(root, dockerfilePath) + let dockerfileRelativePath = defaultDockerfileRelativePath + + if (dockerfileContent !== undefined) { + return { + dockerfilePath, + dockerfileContent, + dockerfileRelativePath, + } + } + + dockerfilePath = path.join(root, fallbackDockerfileName) + dockerfileContent = loadFile(dockerfilePath) + const fallbackDockerfileRelativeName = path.relative(root, dockerfilePath) + dockerfileRelativePath = fallbackDockerfileRelativeName + + if (dockerfileContent !== undefined) { + return { + dockerfilePath, + dockerfileContent, + dockerfileRelativePath, + } + } + + throw new Error( + `No ${asLocalRelative(defaultDockerfileRelativePath)} or ${asLocalRelative( + fallbackDockerfileRelativeName + )} found in the root directory (${root}). You can specify a custom Dockerfile with ${asBold( + '--dockerfile ' + )} option.` + ) +} diff --git a/packages/cli/src/commands/template/generators/file-utils.ts b/packages/cli/src/commands/template/generators/file-utils.ts new file mode 100644 index 0000000..e28dc90 --- /dev/null +++ b/packages/cli/src/commands/template/generators/file-utils.ts @@ -0,0 +1,24 @@ +import * as fs from 'fs' +import * as path from 'path' + +/** + * Write content to a file, creating directories if needed + */ +export async function writeFileContent( + filePath: string, + content: string +): Promise { + if (fs.existsSync(filePath)) { + throw new Error( + `File ${filePath} already exists. Aborting to avoid overwrite.` + ) + } + + // Ensure directory exists + const dir = path.dirname(filePath) + if (!fs.existsSync(dir)) { + await fs.promises.mkdir(dir, { recursive: true }) + } + + await fs.promises.writeFile(filePath, content) +} diff --git a/packages/cli/src/commands/template/generators/handlebars.ts b/packages/cli/src/commands/template/generators/handlebars.ts new file mode 100644 index 0000000..44a0d59 --- /dev/null +++ b/packages/cli/src/commands/template/generators/handlebars.ts @@ -0,0 +1,233 @@ +import { Template, TemplateClass } from 'e2b' +import * as fs from 'fs' +import HandlebarsLib from 'handlebars' +import * as path from 'path' +import { + GeneratedFiles, + Language, + TemplateJSON, + TemplateWithStepsJSON, +} from './types' + +class Handlebars { + private handlebars: typeof HandlebarsLib + + constructor() { + const handlebars = HandlebarsLib.create() + handlebars.registerHelper('eq', function (a: any, b: any, options: any) { + if (a === b) { + // @ts-ignore - this context is provided by Handlebars + return options.fn(this) + } + return '' + }) + + handlebars.registerHelper('escapeQuotes', function (str) { + return str ? str.replace(/'/g, "\\'") : str + }) + + handlebars.registerHelper('escapeDoubleQuotes', function (str) { + return str ? str.replace(/"/g, '\\"') : str + }) + + this.handlebars = handlebars + } + + compile(template: string) { + return this.handlebars.compile(template) + } +} + +interface HandlebarStep { + type: string + args?: string[] + envVars?: Record + src?: string + dest?: string +} + +/** + * Transform template data for Handlebars + */ +export async function transformTemplateData( + template: TemplateClass +): Promise { + // Extract JSON structure from parsed template + const jsonString = await Template.toJSON(template, false) + const json = JSON.parse(jsonString) as TemplateWithStepsJSON + + const transformedSteps: HandlebarStep[] = [] + + for (const step of json.steps) { + switch (step.type) { + case 'ENV': { + // Keep all environment variables from one ENV instruction together + const envVars: Record = {} + for (let i = 0; i < step.args.length; i += 2) { + if (i + 1 < step.args.length) { + envVars[step.args[i]] = step.args[i + 1] + } + } + transformedSteps.push({ + type: 'ENV', + envVars, + }) + break + } + case 'COPY': { + if (step.args.length >= 2) { + const src = step.args[0] + let dest = step.args[1] + if (!dest || dest === '') { + dest = '.' + } + transformedSteps.push({ + type: 'COPY', + src, + dest, + }) + } + break + } + default: + transformedSteps.push({ + type: step.type, + args: step.args, + }) + } + } + + return { + ...json, + steps: transformedSteps, + } +} + +/** + * Convert the template to TypeScript code using Handlebars + */ +export async function generateTypeScriptCode( + template: TemplateClass, + name: string, + cpuCount?: number, + memoryMB?: number +): Promise<{ templateContent: string; buildContent: string }> { + const hb = new Handlebars() + const transformedData = await transformTemplateData(template) + + // Load and compile templates + // In dist, templates are at dist/templates/, __dirname is dist/ + const templatesDir = path.join(__dirname, 'templates') + const templateSource = fs.readFileSync( + path.join(templatesDir, 'typescript-template.hbs'), + 'utf8' + ) + const buildSource = fs.readFileSync( + path.join(templatesDir, 'typescript-build.hbs'), + 'utf8' + ) + + const generateTemplateSource = hb.compile(templateSource) + const generateBuildSource = hb.compile(buildSource) + + // Generate content + const templateData = { + ...transformedData, + } + + const templateContent = generateTemplateSource(templateData) + + const buildContent = generateBuildSource({ + name, + cpuCount, + memoryMB, + }) + + return { + templateContent: templateContent.trim(), + buildContent: buildContent.trim(), + } +} + +/** + * Convert the template to Python code using Handlebars + */ +export async function generatePythonCode( + template: TemplateClass, + name: string, + cpuCount?: number, + memoryMB?: number, + isAsync: boolean = false +): Promise<{ templateContent: string; buildContent: string }> { + const hb = new Handlebars() + const transformedData = await transformTemplateData(template) + + // Load and compile templates + // In dist, templates are at dist/templates/, __dirname is dist/ + const templatesDir = path.join(__dirname, 'templates') + const templateSource = fs.readFileSync( + path.join(templatesDir, 'python-template.hbs'), + 'utf8' + ) + const buildSource = fs.readFileSync( + path.join(templatesDir, `python-build-${isAsync ? 'async' : 'sync'}.hbs`), + 'utf8' + ) + + const generateTemplateSource = hb.compile(templateSource) + const generateBuildSource = hb.compile(buildSource) + + // Generate content + const templateContent = generateTemplateSource({ + ...transformedData, + isAsync, + }) + + const buildContent = generateBuildSource({ + name, + cpuCount, + memoryMB, + }) + + return { + templateContent: templateContent.trim(), + buildContent: buildContent.trim(), + } +} + +/** + * Generate README.md content using Handlebars + */ +export async function generateReadmeContent( + name: string, + templateDir: string, + generatedFiles: GeneratedFiles +): Promise { + const hb = new Handlebars() + + // Load and compile README template + const templatesDir = path.join(__dirname, 'templates') + const readmeSource = fs.readFileSync( + path.join(templatesDir, 'readme.hbs'), + 'utf8' + ) + + const generateReadmeSource = hb.compile(readmeSource) + + // Prepare template data + const templateData = { + name, + templateDir, + templateFile: generatedFiles.templateFile, + buildDevFile: generatedFiles.buildDevFile, + buildProdFile: generatedFiles.buildProdFile, + isTypeScript: generatedFiles.language === Language.TypeScript, + isPython: + generatedFiles.language === Language.PythonSync || + generatedFiles.language === Language.PythonAsync, + isPythonSync: generatedFiles.language === Language.PythonSync, + isPythonAsync: generatedFiles.language === Language.PythonAsync, + } + + return generateReadmeSource(templateData).trim() +} diff --git a/packages/cli/src/commands/template/generators/index.ts b/packages/cli/src/commands/template/generators/index.ts new file mode 100644 index 0000000..536647f --- /dev/null +++ b/packages/cli/src/commands/template/generators/index.ts @@ -0,0 +1,4 @@ +// Re-export all the public APIs from the lib modules +export * from './types' +export * from './template-generator' +export * from './file-utils' diff --git a/packages/cli/src/commands/template/generators/template-generator.ts b/packages/cli/src/commands/template/generators/template-generator.ts new file mode 100644 index 0000000..9967abf --- /dev/null +++ b/packages/cli/src/commands/template/generators/template-generator.ts @@ -0,0 +1,93 @@ +import * as path from 'path' +import { asLocalRelative, asPrimary } from '../../../utils/format' +import { GeneratedFiles, Language, languageDisplay } from './types' +import { generatePythonCode, generateTypeScriptCode } from './handlebars' +import { writeFileContent } from './file-utils' +import { TemplateClass } from 'e2b' + +/** + * Generate and write template files for a given language + */ +export async function generateAndWriteTemplateFiles( + root: string, + name: string, + language: Language, + template: TemplateClass, + cpuCount?: number, + memoryMB?: number +): Promise { + switch (language) { + case Language.TypeScript: { + const { templateContent, buildContent: buildDevContent } = + await generateTypeScriptCode( + template, + `${name}-dev`, + cpuCount, + memoryMB + ) + const { buildContent: buildProdContent } = await generateTypeScriptCode( + template, + name, + cpuCount, + memoryMB + ) + + const templateFile = 'template.ts' + const buildDevFile = 'build.dev.ts' + const buildProdFile = 'build.prod.ts' + + await writeFileContent(path.join(root, templateFile), templateContent) + await writeFileContent(path.join(root, buildDevFile), buildDevContent) + await writeFileContent(path.join(root, buildProdFile), buildProdContent) + + console.log( + `\n✅ Generated ${asPrimary( + languageDisplay[Language.TypeScript] + )} template files:` + ) + console.log(` ${asLocalRelative(templateFile)}`) + console.log(` ${asLocalRelative(buildDevFile)}`) + console.log(` ${asLocalRelative(buildProdFile)}`) + + return { templateFile, buildDevFile, buildProdFile, language } + } + case Language.PythonSync: + case Language.PythonAsync: { + const isAsync = language === Language.PythonAsync + const { templateContent, buildContent: buildDevContent } = + await generatePythonCode( + template, + `${name}-dev`, + cpuCount, + memoryMB, + isAsync + ) + const { buildContent: buildProdContent } = await generatePythonCode( + template, + name, + cpuCount, + memoryMB, + isAsync + ) + + const templateFile = 'template.py' + const buildDevFile = 'build_dev.py' + const buildProdFile = 'build_prod.py' + + await writeFileContent(path.join(root, templateFile), templateContent) + await writeFileContent(path.join(root, buildDevFile), buildDevContent) + await writeFileContent(path.join(root, buildProdFile), buildProdContent) + + console.log( + `\n✅ Generated ${asPrimary(languageDisplay[language])} template files:` + ) + console.log(` ${asLocalRelative(templateFile)}`) + console.log(` ${asLocalRelative(buildDevFile)}`) + console.log(` ${asLocalRelative(buildProdFile)}`) + + return { templateFile, buildDevFile, buildProdFile, language } + } + default: + throw new Error('Unsupported language') + } +} diff --git a/packages/cli/src/commands/template/generators/types.ts b/packages/cli/src/commands/template/generators/types.ts new file mode 100644 index 0000000..cd1bff4 --- /dev/null +++ b/packages/cli/src/commands/template/generators/types.ts @@ -0,0 +1,35 @@ +export enum Language { + TypeScript = 'typescript', + PythonSync = 'python-sync', + PythonAsync = 'python-async', +} + +export const languageDisplay = { + [Language.TypeScript]: 'TypeScript', + [Language.PythonSync]: 'Python (sync)', + [Language.PythonAsync]: 'Python (async)', +} + +export interface TemplateJSON { + fromImage?: string + fromTemplate?: string + startCmd?: string + readyCmd?: string + force: boolean +} + +export interface TemplateWithStepsJSON extends TemplateJSON { + steps: Array<{ + type: string + args: string[] + filesHash?: string + force?: boolean + }> +} + +export interface GeneratedFiles { + templateFile: string + buildDevFile: string + buildProdFile: string + language: Language +} diff --git a/packages/cli/src/commands/template/index.ts b/packages/cli/src/commands/template/index.ts new file mode 100644 index 0000000..fc8d336 --- /dev/null +++ b/packages/cli/src/commands/template/index.ts @@ -0,0 +1,21 @@ +import * as commander from 'commander' + +import { createCommand } from './create' +import { buildCommand } from './build' +import { deleteCommand } from './delete' +import { initCommand } from './init' +import { listCommand } from './list' +import { migrateCommand } from './migrate' +import { publishCommand, unPublishCommand } from './publish' + +export const templateCommand = new commander.Command('template') + .description('manage sandbox templates') + .alias('tpl') + .addCommand(createCommand) + .addCommand(buildCommand, { hidden: true }) + .addCommand(listCommand) + .addCommand(initCommand) + .addCommand(deleteCommand) + .addCommand(publishCommand) + .addCommand(unPublishCommand) + .addCommand(migrateCommand) diff --git a/packages/cli/src/commands/template/init.ts b/packages/cli/src/commands/template/init.ts new file mode 100644 index 0000000..e1b83e7 --- /dev/null +++ b/packages/cli/src/commands/template/init.ts @@ -0,0 +1,312 @@ +import { input, select } from '@inquirer/prompts' +import PackageJson from '@npmcli/package-json' +import * as commander from 'commander' +import { Template } from 'e2b' +import * as fs from 'fs' +import * as path from 'path' +import { pathOption } from 'src/options' +import { getRoot } from 'src/utils/filesystem' +import { asPrimary } from 'src/utils/format' +import { validateTemplateName } from 'src/utils/templateName' +import { + generateAndWriteTemplateFiles, + GeneratedFiles, + Language, + languageDisplay, +} from './generators' +import { generateReadmeContent } from './generators/handlebars' + +const DEFAULT_TEMPLATE_NAME = 'my-template' + +/** + * Generate template files using shared template generation logic + */ +async function generateTemplateFiles( + root: string, + name: string, + language: Language, + cpuCount?: number, + memoryMB?: number +): Promise { + const template = Template().fromBaseImage().runCmd('echo Hello World E2B!') + + return generateAndWriteTemplateFiles( + root, + name, + language, + template, + cpuCount, + memoryMB + ) +} + +/** + * Add build scripts to Makefile if it exists or create a new one + */ +async function addMakefileScripts( + root: string, + files: GeneratedFiles, + templateDirName: string +): Promise { + try { + const makefileName = 'Makefile' + const makefileExists = fs.existsSync(path.join(root, makefileName)) + + let cdPrefix = '' + if (makefileExists) { + cdPrefix = `cd ${templateDirName} && ` + } + + const makefileContent = ` +.PHONY: e2b:build:dev +e2b:build:dev: +\t${cdPrefix}python ${files.buildDevFile} + +.PHONY: e2b:build:prod +e2b:build:prod: +\t${cdPrefix}python ${files.buildProdFile} +` + + if (makefileExists) { + const makefilePath = path.join(root, makefileName) + await fs.promises.appendFile(makefilePath, '\n' + makefileContent, 'utf8') + } else { + // Create a basic Makefile if it doesn't exist + const makefilePath = path.join(root, templateDirName, makefileName) + await fs.promises.writeFile(makefilePath, makefileContent, 'utf8') + } + + console.log('\n📝 Added build scripts to Makefile:') + console.log( + ` ${asPrimary('make e2b:build:dev')} - Build development template` + ) + console.log( + ` ${asPrimary('make e2b:build:prod')} - Build production template` + ) + } catch (err) { + console.warn( + '\n⚠️ Could not add scripts to Makefile:', + err instanceof Error ? err.message : err + ) + } +} + +/** + * Add build scripts to package.json if it exists or create a new one + */ +async function addPackageJsonScripts( + root: string, + files: GeneratedFiles, + templateDirName: string +): Promise { + try { + let cdPrefix = '' + let pkgJson: PackageJson + try { + // The library expects the directory path, not the full file path + pkgJson = await PackageJson.load(root) + cdPrefix = `cd ${templateDirName} && ` + } catch (error) { + // Handle the case where package.json does not exist + const createRoot = path.join(root, templateDirName) + pkgJson = await PackageJson.create(createRoot) + } + + pkgJson.update({ + scripts: { + ...pkgJson.content.scripts, + 'e2b:build:dev': `${cdPrefix}npx tsx ${files.buildDevFile}`, + 'e2b:build:prod': `${cdPrefix}npx tsx ${files.buildProdFile}`, + }, + }) + + // Save the changes + await pkgJson.save() + + console.log('\n📝 Added build scripts to package.json:') + console.log( + ` ${asPrimary('npm run e2b:build:dev')} - Build development template` + ) + console.log( + ` ${asPrimary('npm run e2b:build:prod')} - Build production template` + ) + } catch (err) { + console.warn( + '\n⚠️ Could not add scripts to package.json:', + err instanceof Error ? err.message : err + ) + } +} + +export const initCommand = new commander.Command('init') + .description('initialize a new sandbox template using the SDK') + .addOption(pathOption) + .option('-n, --name ', 'template name', (value) => { + try { + return validateTemplateName(value) + } catch (err) { + throw new commander.InvalidArgumentError( + err instanceof Error ? err.message : String(err) + ) + } + }) + .option( + '-l, --language ', + `target language: ${Object.values(Language).join(', ')}`, + (value) => { + if (!Object.values(Language).includes(value as Language)) { + throw new commander.InvalidArgumentError( + `Invalid language. Must be one of: ${Object.values(Language).join( + ', ' + )}` + ) + } + return value as Language + } + ) + .alias('it') + .action( + async (opts: { path?: string; name?: string; language?: Language }) => { + try { + process.stdout.write('\n') + + const root = getRoot(opts.path) + + console.log('🚀 Initializing Sandbox Template...\n') + + // Step 1: Get template name (from CLI or prompt) + let templateName: string = opts.name ?? DEFAULT_TEMPLATE_NAME + if (opts.name === undefined) { + templateName = await input({ + message: 'Enter template name:', + default: DEFAULT_TEMPLATE_NAME, + validate: (input: string) => { + try { + validateTemplateName(input) + } catch (err) { + return err instanceof Error ? err.message : err + } + + return true + }, + }) + } + templateName = validateTemplateName(templateName) + console.log(`Using template name: ${templateName}`) + + // Step 2: Get language (from CLI or prompt) + let language: Language + if (opts.language) { + language = opts.language + console.log(`Using language: ${languageDisplay[language]}`) + } else { + language = await select({ + message: 'Select target language for template files:', + choices: [ + { + name: languageDisplay[Language.TypeScript], + value: Language.TypeScript, + description: + 'Generate .ts files for JavaScript/TypeScript projects', + }, + { + name: languageDisplay[Language.PythonSync], + value: Language.PythonSync, + description: 'Generate synchronous Python template files', + }, + { + name: languageDisplay[Language.PythonAsync], + value: Language.PythonAsync, + description: 'Generate asynchronous Python template files', + }, + ], + default: Language.TypeScript, + }) + } + + // Step 3: Create template directory - fail if it already exists + const templateDirName = templateName + const templateDir = path.join(root, templateDirName) + + if (fs.existsSync(templateDir)) { + throw new Error( + `Directory '${templateDirName}' already exists. Please choose a different template name or remove the existing directory.` + ) + } + + await fs.promises.mkdir(templateDir, { recursive: true }) + + // Step 4: Generate template and build files in the template directory + const generatedFiles = await generateTemplateFiles( + templateDir, + templateName, + language + ) + + // Step 5: Add scripts + switch (language) { + case Language.TypeScript: + await addPackageJsonScripts(root, generatedFiles, templateDirName) + break + case Language.PythonAsync: + case Language.PythonSync: + await addMakefileScripts(root, generatedFiles, templateDirName) + break + default: + throw new Error('Unsupported language for scripts') + } + + // Step 6: Create README.md + const readmeContent = await generateReadmeContent( + templateName, + templateDirName, + generatedFiles + ) + const readmeFilePath = path.join(templateDir, 'README.md') + await fs.promises.writeFile(readmeFilePath, readmeContent, 'utf8') + + console.log('\n🎉 Template initialized successfully!') + console.log( + `\nTemplate created in: ${asPrimary(`./${templateDirName}/`)}` + ) + console.log('\n🔨 To get started with your template:') + + switch (language) { + case Language.TypeScript: + console.log( + ` ${asPrimary('npm install e2b')} (install e2b dependency)` + ) + console.log( + ` ${asPrimary('npm run e2b:build:dev')} (for development)` + ) + console.log( + ` ${asPrimary('npm run e2b:build:prod')} (for production)` + ) + break + case Language.PythonAsync: + case Language.PythonSync: + console.log( + ` ${asPrimary('pip install e2b')} (install e2b dependency)` + ) + console.log( + ` ${asPrimary('make e2b:build:dev')} (for development)` + ) + console.log( + ` ${asPrimary('make e2b:build:prod')} (for production)` + ) + break + default: + throw new Error('Unsupported language for instructions') + } + + console.log( + `\nLearn more about Sandbox Templates: ${asPrimary( + 'https://e2b.dev/docs' + )}\n` + ) + } catch (err: any) { + console.error(err) + process.exit(1) + } + } + ) diff --git a/packages/cli/src/commands/template/list.ts b/packages/cli/src/commands/template/list.ts new file mode 100644 index 0000000..abe40ab --- /dev/null +++ b/packages/cli/src/commands/template/list.ts @@ -0,0 +1,127 @@ +import * as tablePrinter from 'console-table-printer' +import * as commander from 'commander' +import * as e2b from 'e2b' + +import { listAliases } from '../../utils/format' +import { sortTemplatesAliases } from 'src/utils/templateSort' +import { client, ensureAPIKey, resolveTeamId } from 'src/api' +import { teamOption } from '../../options' +import { handleE2BRequestError } from '../../utils/errors' + +export const listCommand = new commander.Command('list') + .description('list sandbox templates') + .alias('ls') + .addOption(teamOption) + .option('-f, --format ', 'output format, eg. json, pretty') + .action(async (opts: { team: string; format: string }) => { + try { + const format = opts.format || 'pretty' + ensureAPIKey() + process.stdout.write('\n') + + const templates = await listSandboxTemplates({ + teamID: resolveTeamId(opts.team), + }) + + for (const template of templates) { + sortTemplatesAliases(template.aliases) + } + + if (format === 'pretty') { + renderTable(templates) + } else if (format === 'json') { + console.log(JSON.stringify(templates, null, 2)) + } else { + console.error(`Unsupported output format: ${format}`) + process.exit(1) + } + } catch (err: any) { + console.error(err) + process.exit(1) + } + }) + +function renderTable(templates: e2b.components['schemas']['Template'][]) { + if (!templates?.length) { + console.log('No templates found.') + return + } + + const table = new tablePrinter.Table({ + title: 'Sandbox templates', + columns: [ + { name: 'visibility', alignment: 'left', title: 'Access' }, + { name: 'templateID', alignment: 'left', title: 'Template ID' }, + { + name: 'aliases', + alignment: 'left', + title: 'Template Name', + color: 'orange', + maxLen: 20, + }, + { name: 'cpuCount', alignment: 'right', title: 'vCPUs' }, + { name: 'memoryMB', alignment: 'right', title: 'RAM MiB' }, + { name: 'createdBy', alignment: 'right', title: 'Created by' }, + { name: 'createdAt', alignment: 'right', title: 'Created at' }, + { name: 'diskSizeMB', alignment: 'right', title: 'Disk size MiB' }, + { name: 'envdVersion', alignment: 'right', title: 'Envd version' }, + ], + disabledColumns: [ + 'public', + 'buildID', + 'buildCount', + 'lastSpawnedAt', + 'spawnCount', + 'updatedAt', + ], + rows: templates.map((template) => ({ + ...template, + visibility: template.public ? 'Public' : 'Private', + aliases: listAliases(template.aliases), + createdBy: template.createdBy?.email, + createdAt: new Date(template.createdAt).toLocaleDateString(), + })), + style: { + headerTop: { + left: '', + right: '', + mid: '', + other: '', + }, + headerBottom: { + left: '', + right: '', + mid: '', + other: '', + }, + tableBottom: { + left: '', + right: '', + mid: '', + other: '', + }, + vertical: '', + }, + colorMap: { + orange: '\x1b[38;5;216m', + }, + }) + table.printTable() + + process.stdout.write('\n') +} + +export async function listSandboxTemplates({ + teamID, +}: { + teamID?: string +}): Promise { + const templates = await client.api.GET('/templates', { + params: { + query: { teamID }, + }, + }) + + handleE2BRequestError(templates, 'Error getting templates') + return templates.data +} diff --git a/packages/cli/src/commands/template/migrate.ts b/packages/cli/src/commands/template/migrate.ts new file mode 100644 index 0000000..6bfbb6d --- /dev/null +++ b/packages/cli/src/commands/template/migrate.ts @@ -0,0 +1,317 @@ +import { select } from '@inquirer/prompts' +import * as commander from 'commander' +import { Template, TemplateBuilder, TemplateClass } from 'e2b' +import * as fs from 'fs' +import * as path from 'path' +import { E2BConfig, getConfigPath, loadConfig } from '../../config' +import { defaultDockerfileName } from '../../docker/constants' +import { configOption, parsePositiveInt, pathOption } from '../../options' +import { getRoot } from '../../utils/filesystem' +import { asLocal, asLocalRelative, asPrimary } from '../../utils/format' +import { getDockerfile } from './dockerfile' +import { validateTemplateName } from '../../utils/templateName' +import { + generateAndWriteTemplateFiles, + Language, + languageDisplay, +} from './generators' + +/** + * Migrate Dockerfile to a specific target language using SDK + */ +async function migrateToLanguage( + root: string, + config: E2BConfig, + dockerfileContent: string, + language: Language, + nameOverride?: string +): Promise { + // Initialize template with file context + const template = Template({ + fileContextPath: root, + }) + + // Parse Dockerfile using SDK + let baseTemplate: TemplateBuilder + try { + baseTemplate = template.fromDockerfile(dockerfileContent) + } catch (error) { + console.warn( + "\n⚠️ Unfortunately, we weren't able to fully convert the template to the new SDK format." + ) + console.warn( + '\nPlease build the Docker image manually, push it to a repository of your choice, and then reference it.' + ) + console.warn("\nHere's an example of how to build the Docker image:") + console.warn( + ` ${asPrimary( + 'docker build -f e2b.Dockerfile --platform linux/amd64 -t your-image-tag .' + )}` + ) + console.warn( + '\nAfter building and pushing your image to a repository of your choice, update the generated template files to use the actual image tag.' + ) + if (error instanceof Error) { + console.warn('\nCause:', error.message) + } + baseTemplate = template.fromImage('my-custom-image') + } + + // Apply config start/ready commands + let parsedTemplate: TemplateClass = baseTemplate + if (config.start_cmd) { + parsedTemplate = baseTemplate.setStartCmd( + config.start_cmd, + config.ready_cmd || 'sleep 20' + ) + } else if (config.ready_cmd) { + parsedTemplate = baseTemplate.setReadyCmd(config.ready_cmd) + } + + const name = nameOverride || config.template_name || config.template_id + if (!name) { + throw new Error('Template name or ID is required') + } + + // Generate code for the target language using shared functionality + await generateAndWriteTemplateFiles( + root, + name, + language, + parsedTemplate, + config.cpu_count, + config.memory_mb + ) +} + +export const migrateCommand = new commander.Command('migrate') + .description( + `migrate ${asLocal('e2b.Dockerfile')} and ${asLocal( + 'e2b.toml' + )} to new Template SDK format` + ) + .option( + '-d, --dockerfile ', + `specify path to Dockerfile. Defaults to ${asLocal('e2b.Dockerfile')}` + ) + .addOption(configOption) + .option( + '-n, --name ', + 'override the template name used in the generated files. Defaults to the template name or ID from the config file.', + (value) => { + try { + return validateTemplateName(value) + } catch (err) { + throw new commander.InvalidArgumentError( + err instanceof Error ? err.message : String(err) + ) + } + } + ) + .option( + '-c, --cmd ', + 'override the command that will be executed when the sandbox is started.' + ) + .option( + '--ready-cmd ', + 'override the command that will need to exit 0 for the template to be ready.' + ) + .option( + '--cpu-count ', + 'override the number of CPUs that will be used to run the sandbox.', + parsePositiveInt('CPU count') + ) + .option( + '--memory-mb ', + 'override the amount of memory in megabytes that will be used to run the sandbox. Must be an even number.', + parsePositiveInt('Memory in megabytes') + ) + .option( + '-l, --language ', + `specify target language: ${Object.values(Language).join(', ')}`, + (value) => { + if (!Object.values(Language).includes(value as Language)) { + throw new commander.InvalidArgumentError( + `Invalid language. Must be one of: ${Object.values(Language).join( + ', ' + )}` + ) + } + return value as Language + } + ) + .addOption(pathOption) + .action( + async (opts: { + dockerfile?: string + config?: string + path?: string + language?: Language + name?: string + cmd?: string + readyCmd?: string + cpuCount?: number + memoryMb?: number + }) => { + let success = false + try { + console.log('\n🔄 Migrating template configuration to SDK format...\n') + + // Validate memory override + if (opts.memoryMb && opts.memoryMb % 2 !== 0) { + throw new Error( + `The memory in megabytes must be an even number. You provided ${asLocal( + opts.memoryMb.toFixed(0) + )}.` + ) + } + + const root = getRoot(opts.path) + const configPath = getConfigPath(root, opts.config) + + const { dockerfileContent, dockerfilePath, dockerfileRelativePath } = + getDockerfile(root, opts.dockerfile) + + let config: E2BConfig = { + template_id: 'name-your-template', + dockerfile: defaultDockerfileName, + } + + // Validate config file exists + if (fs.existsSync(configPath)) { + config = await loadConfig(configPath) + } else { + console.error( + `Config file ${asLocalRelative( + path.relative(root, configPath) + )} not found. Using defaults.` + ) + } + + // Apply command-line overrides on top of the loaded config + if (opts.cmd !== undefined) { + config.start_cmd = opts.cmd + } + if (opts.readyCmd !== undefined) { + config.ready_cmd = opts.readyCmd + } + if (opts.cpuCount !== undefined) { + config.cpu_count = opts.cpuCount + } + if (opts.memoryMb !== undefined) { + config.memory_mb = opts.memoryMb + } + + // Determine target language + let language: Language + if (opts.language) { + language = opts.language + console.log(`Using language: ${asPrimary(languageDisplay[language])}`) + } else { + // Prompt for language selection + language = await select({ + message: 'Select target language for Template SDK:', + choices: [ + { + name: languageDisplay[Language.TypeScript], + value: Language.TypeScript, + description: + 'Generate .ts files for JavaScript/TypeScript projects', + }, + { + name: languageDisplay[Language.PythonSync], + value: Language.PythonSync, + description: 'Generate synchronous Python template files', + }, + { + name: languageDisplay[Language.PythonAsync], + value: Language.PythonAsync, + description: 'Generate asynchronous Python template files', + }, + ], + default: Language.TypeScript, + }) + } + + // Perform migration + await migrateToLanguage( + root, + config, + dockerfileContent, + language, + opts.name + ) + + // Rename old files to .old extensions + const oldFilesRenamed: { oldPath: string; newPath: string }[] = [] + + // Rename Dockerfile if it exists + if (fs.existsSync(dockerfilePath)) { + const oldDockerfilePath = `${dockerfilePath}.old` + fs.renameSync(dockerfilePath, oldDockerfilePath) + oldFilesRenamed.push({ + oldPath: dockerfileRelativePath, + newPath: path.relative(root, oldDockerfilePath), + }) + } + + // Rename e2b.toml if it exists + if (fs.existsSync(configPath)) { + const oldConfigPath = `${configPath}.old` + fs.renameSync(configPath, oldConfigPath) + oldFilesRenamed.push({ + oldPath: path.relative(root, configPath), + newPath: path.relative(root, oldConfigPath), + }) + } + + if (oldFilesRenamed.length > 0) { + console.log('\n📁 Old template files no longer needed:') + oldFilesRenamed.forEach((file) => { + console.log( + ` ${asLocalRelative(file.oldPath)} → ${asLocalRelative(file.newPath)}` + ) + }) + } + + console.log('\n🎉 Migration completed successfully!') + + console.log('\n🔨 To get started with your template:') + if (language === Language.TypeScript) { + console.log( + ` ${asPrimary('npm install e2b')} (install e2b dependency)` + ) + console.log( + ` ${asPrimary('npx tsx build.dev.ts')} (run development build)` + ) + console.log( + ` ${asPrimary('npx tsx build.prod.ts')} (run production build)` + ) + } else { + console.log( + ` ${asPrimary('pip install e2b')} (install e2b dependency)` + ) + console.log( + ` ${asPrimary('python build_dev.py')} (run development build)` + ) + console.log( + ` ${asPrimary('python build_prod.py')} (run production build)` + ) + } + + console.log( + `\nLearn more about Template SDK: ${asPrimary( + 'https://e2b.dev/docs' + )}\n` + ) + success = true + } catch (err: any) { + console.error(`Migration failed: ${err.message}`) + process.exit(1) + } + + if (success) { + process.exit(0) + } + } + ) diff --git a/packages/cli/src/commands/template/publish.ts b/packages/cli/src/commands/template/publish.ts new file mode 100644 index 0000000..cfcbdc8 --- /dev/null +++ b/packages/cli/src/commands/template/publish.ts @@ -0,0 +1,232 @@ +import * as commander from 'commander' +import * as chalk from 'chalk' +import * as fs from 'fs' + +import { + asBold, + asFormattedError, + asFormattedSandboxTemplate, + asLocal, + asLocalRelative, +} from 'src/utils/format' +import { + configOption, + pathOption, + selectMultipleOption, + teamOption, +} from 'src/options' +import { configName, E2BConfig, getConfigPath, loadConfig } from 'src/config' +import { getRoot } from 'src/utils/filesystem' +import { listSandboxTemplates } from './list' +import { getPromptTemplates } from 'src/utils/templatePrompt' +import { confirm } from 'src/utils/confirm' +import { client, resolveTeamId } from 'src/api' +import { handleE2BRequestError } from '../../utils/errors' + +async function publishTemplate(templateID: string, publish: boolean) { + const res = await client.api.PATCH('/v2/templates/{templateID}', { + params: { + path: { + templateID, + }, + }, + body: { + public: publish, + }, + }) + + handleE2BRequestError( + res, + `Error ${publish ? 'publishing' : 'unpublishing'} sandbox template` + ) + + return res.data?.names ?? [] +} + +async function templateAction( + publish: boolean, + template: string, + opts: { + path?: string + config?: string + yes?: boolean + select?: boolean + team?: string + } +) { + try { + let teamId = opts.team + + const root = getRoot(opts.path) + + const templates: (Pick & { + configPath?: string + })[] = [] + + if (template) { + templates.push({ + template_id: template, + }) + } else if (opts.select) { + teamId = resolveTeamId(teamId) + + const allTemplates = await listSandboxTemplates({ + teamID: teamId, + }) + + const filteredTemplates = allTemplates.filter( + (e) => !e.public === publish + ) + + if (filteredTemplates.length === 0) { + console.log( + `No sandbox templates available ${ + publish ? 'to publish' : 'to unpublish' + } found` + ) + return + } + + const selectedTemplates = await getPromptTemplates( + filteredTemplates, + `Select sandbox templates to ${publish ? 'publish' : 'unpublish'}` + ) + templates.push( + ...selectedTemplates.map((e) => ({ + template_id: e.templateID, + ...e, + })) + ) + + if (!templates || templates.length === 0) { + console.log('No sandbox templates selected') + return + } + } else { + const configPath = getConfigPath(root, opts.config) + const config = fs.existsSync(configPath) + ? await loadConfig(configPath) + : undefined + + if (!config) { + console.log( + `No ${asLocal(configName)} found in ${asLocalRelative( + root + )}. Specify sandbox template with ${asBold( + '[template]' + )} argument or use interactive mode with ${asBold('-s')} flag.` + ) + return + } + + templates.push({ + ...config, + configPath, + }) + } + + if (!templates || templates.length === 0) { + console.log( + `No sandbox templates selected. Specify sandbox template with ${asBold( + '[template]' + )} argument or use interactive mode with ${asBold('-s')} flag.` + ) + return + } + + console.log( + chalk.default.underline( + `Sandbox templates to ${publish ? 'publish' : 'unpublish'}` + ) + ) + templates.forEach((e) => + console.log( + asFormattedSandboxTemplate( + { ...e, templateID: e.template_id }, + e.configPath + ) + ) + ) + process.stdout.write('\n') + + if (!opts.yes) { + const confirmed = await confirm( + `Do you really want to ${publish ? 'publish' : 'unpublish'} ${ + templates.length === 1 ? 'this template' : 'these templates' + }?\n⚠️ This will make the ${ + templates.length === 1 ? 'template' : 'templates' + } ${ + publish + ? 'public to everyone outside your team' + : 'private to your team' + }` + ) + + if (!confirmed) { + console.log('Canceled') + return + } + } + + await Promise.all( + templates.map(async (e) => { + console.log( + `- ${ + publish ? 'Publishing' : 'Unpublishing' + } sandbox template ${asFormattedSandboxTemplate( + { ...e, templateID: e.template_id }, + e.configPath + )}` + ) + const names = await publishTemplate(e.template_id, publish) + if (publish && names.length > 0) { + console.log(` Published as: ${asBold(names.join(', '))}`) + } + }) + ) + process.stdout.write('\n') + } catch (err: any) { + console.error(asFormattedError(err.message)) + process.exit(1) + } +} + +export const publishCommand = new commander.Command('publish') + .description('publish sandbox template') + .argument( + '[template]', + `specify ${asBold( + '[template]' + )} to publish it. If you dont specify ${asBold( + '[template]' + )} the command will try to publish sandbox template defined by ${asLocal( + 'e2b.toml' + )}.` + ) + .addOption(pathOption) + .addOption(configOption) + .addOption(selectMultipleOption) + .addOption(teamOption) + .alias('pb') + .option('-y, --yes', 'skip manual publish confirmation') + .action(templateAction.bind(null, true)) + +export const unPublishCommand = new commander.Command('unpublish') + .description('unpublish sandbox template') + .argument( + '[template]', + `specify ${asBold( + '[template]' + )} to unpublish it. If you don't specify ${asBold( + '[template]' + )} the command will try to unpublish sandbox template defined by ${asLocal( + 'e2b.toml' + )}.` + ) + .addOption(pathOption) + .addOption(configOption) + .addOption(selectMultipleOption) + .addOption(teamOption) + .alias('upb') + .option('-y, --yes', 'skip manual unpublish confirmation') + .action(templateAction.bind(null, false)) diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts new file mode 100644 index 0000000..bc136d9 --- /dev/null +++ b/packages/cli/src/config/index.ts @@ -0,0 +1,129 @@ +import * as yup from 'yup' +import * as toml from '@iarna/toml' +import * as fsPromise from 'fs/promises' +import * as fs from 'fs' +import * as path from 'path' + +import { asFormattedSandboxTemplate, asLocalRelative } from 'src/utils/format' + +export const configName = 'e2b.toml' + +function getConfigHeader(config: E2BConfig) { + return `# This is a config for E2B sandbox template. +# You can use template ID (${config.template_id}) ${ + config.template_name ? `or template name (${config.template_name}) ` : '' + }to create a sandbox: + +# Python SDK +# from e2b import Sandbox, AsyncSandbox +# sandbox = Sandbox.create("${ + config.template_name || config.template_id + }") # Sync sandbox +# sandbox = await AsyncSandbox.create("${ + config.template_name || config.template_id + }") # Async sandbox + +# JS SDK +# import { Sandbox } from 'e2b' +# const sandbox = await Sandbox.create('${ + config.template_name || config.template_id + }') + +` +} + +export const configSchema = yup.object({ + template_id: yup.string().required(), + template_name: yup.string().optional(), + dockerfile: yup.string().required(), + start_cmd: yup.string().optional(), + ready_cmd: yup.string().optional(), + cpu_count: yup.number().integer().min(1).optional(), + memory_mb: yup.number().integer().min(128).optional(), + team_id: yup.string().optional(), +}) + +export type E2BConfig = yup.InferType + +interface Migration { + from: string + to: string +} + +// List of name migrations from old config format to new one. +// We need to keep this list to be able to migrate old configs to new format. +const migrations: Migration[] = [ + { + from: 'id', + to: 'template_id', + }, + { + from: 'name', + to: 'template_name', + }, +] + +function applyMigrations(config: toml.JsonMap, migrations: Migration[]) { + for (const migration of migrations) { + const from = migration.from + const to = migration.to + + if (config[from]) { + config[to] = config[from] + delete config[from] + } + } + + return config +} + +export async function loadConfig(configPath: string) { + const tomlRaw = await fsPromise.readFile(configPath, 'utf-8') + const config = toml.parse(tomlRaw) + const migratedConfig = applyMigrations(config, migrations) + + return (await configSchema.validate(migratedConfig)) as E2BConfig +} + +export async function saveConfig( + configPath: string, + config: E2BConfig, + overwrite?: boolean +) { + try { + if (!overwrite) { + const configExists = fs.existsSync(configPath) + if (configExists) { + throw new Error( + `Config already exists on path ${asLocalRelative(configPath)}` + ) + } + } + + const validatedConfig: any = await configSchema.validate(config, { + stripUnknown: true, + }) + + const tomlRaw = toml.stringify(validatedConfig) + await fsPromise.writeFile(configPath, getConfigHeader(config) + tomlRaw) + } catch (err: any) { + throw new Error( + `E2B sandbox template config ${asFormattedSandboxTemplate( + { + templateID: config.template_id, + }, + configPath + )} cannot be saved: ${err.message}` + ) + } +} + +export async function deleteConfig(configPath: string) { + await fsPromise.unlink(configPath) +} + +export function getConfigPath(root: string, configPath?: string) { + if (configPath && path.isAbsolute(configPath)) return configPath + + return path.join(root, configPath || configName) +} diff --git a/packages/cli/src/docker/constants.ts b/packages/cli/src/docker/constants.ts new file mode 100644 index 0000000..83fe4ad --- /dev/null +++ b/packages/cli/src/docker/constants.ts @@ -0,0 +1,2 @@ +export const defaultDockerfileName = 'e2b.Dockerfile' +export const fallbackDockerfileName = 'Dockerfile' diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..00ada9c --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env -S node --enable-source-maps + +import simpleUpdateNotifier from 'simple-update-notifier' +import * as commander from 'commander' +import * as packageJSON from '../package.json' +import { program } from './commands' +import { commands2md } from './utils/commands2md' + +export const pkg = packageJSON + +const updateCheck = simpleUpdateNotifier({ + pkg, + updateCheckInterval: 1000 * 60 * 60 * 8, // 8 hours +}).catch((e) => { + if (process.env.DEBUG) { + console.error('Update check failed:', e) + } +}) + +const prog = program.version( + packageJSON.version, + undefined, + 'display E2B CLI version' +) + +if (process.env.NODE_ENV === 'development') { + prog + .addOption(new commander.Option('-cmd2md').hideHelp()) + .on('option:-cmd2md', () => { + commands2md(program.commands as any) + process.exit(0) + }) +} + +async function main() { + await prog.parseAsync() + await updateCheck +} + +main() diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts new file mode 100644 index 0000000..0aa11a4 --- /dev/null +++ b/packages/cli/src/options.ts @@ -0,0 +1,43 @@ +import * as commander from 'commander' + +import { asBold, asLocal } from './utils/format' + +/** + * Parse a CLI option as a positive integer, rejecting non-numeric values so + * they don't silently become NaN. + */ +export function parsePositiveInt(label: string): (value: string) => number { + return (value) => { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < 1) { + throw new commander.InvalidArgumentError( + `${label} must be a positive integer. You provided ${asLocal(value)}.` + ) + } + return parsed + } +} + +export const pathOption = new commander.Option( + '-p, --path ', + `change root directory where command is executed to ${asBold( + '' + )} directory` +) + +export const configOption = new commander.Option( + '--config ', + `specify path to the E2B config toml. By default E2B tries to find ${asBold( + './e2b.toml' + )} in root directory. We recommend using the new build system (https://e2b.dev/docs/template/defining-template) that does not use config files.` +) + +export const selectMultipleOption = new commander.Option( + '-s, --select', + 'select sandbox template from interactive list' +) + +export const teamOption = new commander.Option( + '-t, --team ', + 'specify the team ID that the operation will be associated with. You can find team ID in the team settings in the E2B dashboard (https://e2b.dev/dashboard?tab=team).' +) diff --git a/packages/cli/src/templates/python-build-async.hbs b/packages/cli/src/templates/python-build-async.hbs new file mode 100644 index 0000000..95e885d --- /dev/null +++ b/packages/cli/src/templates/python-build-async.hbs @@ -0,0 +1,21 @@ +import asyncio +from e2b import AsyncTemplate, default_build_logger +from template import template + + +async def main(): + await AsyncTemplate.build( + template, + "{{name}}", +{{#if cpuCount}} + cpu_count={{cpuCount}}, +{{/if}} +{{#if memoryMB}} + memory_mb={{memoryMB}}, +{{/if}} + on_build_logs=default_build_logger(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/cli/src/templates/python-build-sync.hbs b/packages/cli/src/templates/python-build-sync.hbs new file mode 100644 index 0000000..78f9c32 --- /dev/null +++ b/packages/cli/src/templates/python-build-sync.hbs @@ -0,0 +1,16 @@ +from e2b import Template, default_build_logger +from template import template + + +if __name__ == "__main__": + Template.build( + template, + "{{name}}", + {{#if cpuCount}} + cpu_count={{cpuCount}}, + {{/if}} + {{#if memoryMB}} + memory_mb={{memoryMB}}, + {{/if}} + on_build_logs=default_build_logger(), + ) diff --git a/packages/cli/src/templates/python-template.hbs b/packages/cli/src/templates/python-template.hbs new file mode 100644 index 0000000..a781bb6 --- /dev/null +++ b/packages/cli/src/templates/python-template.hbs @@ -0,0 +1,36 @@ +from e2b import {{#if isAsync}}AsyncTemplate{{else}}Template{{/if}} + +template = ( + {{#if isAsync}}AsyncTemplate{{else}}Template{{/if}}() +{{#if fromImage}} + .from_image("{{{fromImage}}}") +{{/if}} +{{#each steps}} +{{#eq type "WORKDIR"}} + .set_workdir("{{{args.[0]}}}") +{{/eq}} +{{#eq type "USER"}} + .set_user("{{{args.[0]}}}") +{{/eq}} +{{#eq type "ENV"}} + .set_envs({ +{{#each envVars}} + "{{{@key}}}": "{{{this}}}", +{{/each}} + }) +{{/eq}} +{{#eq type "RUN"}} + .run_cmd("{{{args.[0]}}}") +{{/eq}} +{{#eq type "COPY"}} + .copy("{{{src}}}", "{{{dest}}}") +{{/eq}} +{{/each}} +{{#if startCmd}} +{{#if readyCmd}} + .set_start_cmd("sudo {{{escapeDoubleQuotes startCmd}}}", "{{{escapeDoubleQuotes readyCmd}}}") +{{/if}} +{{else if readyCmd}} + .set_ready_cmd("sudo {{{escapeDoubleQuotes readyCmd}}}") +{{/if}} +) diff --git a/packages/cli/src/templates/readme.hbs b/packages/cli/src/templates/readme.hbs new file mode 100644 index 0000000..94bb383 --- /dev/null +++ b/packages/cli/src/templates/readme.hbs @@ -0,0 +1,99 @@ +# {{name}} - E2B Sandbox Template + +This is an E2B sandbox template that allows you to run code in a controlled environment. + +## Prerequisites + +Before you begin, make sure you have: +- An E2B account (sign up at [e2b.dev](https://e2b.dev)) +- Your E2B API key (get it from your [E2B dashboard](https://e2b.dev/dashboard)) +{{#if isTypeScript}}- Node.js and npm/yarn (or similar) installed{{else if isPython}}- Python installed{{/if}} + +## Configuration + +1. Create a `.env` file in your project root or set the environment variable: + ``` + E2B_API_KEY=your_api_key_here + ``` + +## Installing Dependencies + +```bash +{{#if isTypeScript}} +npm install e2b +{{else if isPython}} +pip install e2b +{{/if}} +``` + +## Building the Template + +```bash +{{#if isTypeScript}} +# For development +npm run e2b:build:dev + +# For production +npm run e2b:build:prod +{{else if isPython}} +# For development +make e2b:build:dev + +# For production +make e2b:build:prod +{{/if}} +``` + +## Using the Template in a Sandbox + +Once your template is built, you can use it in your E2B sandbox: + +{{#if isTypeScript}} +```typescript +import { Sandbox } from 'e2b' + +// Create a new sandbox instance +const sandbox = await Sandbox.create('{{name}}') + +// Your sandbox is ready to use! +console.log('Sandbox created successfully') +``` +{{else if isPythonSync}} +```python +from e2b import Sandbox + +# Create a new sandbox instance +sandbox = Sandbox.create('{{name}}') + +# Your sandbox is ready to use! +print('Sandbox created successfully') +``` +{{else if isPythonAsync}} +```python +from e2b import AsyncSandbox +import asyncio + +async def main(): + # Create a new sandbox instance + sandbox = await AsyncSandbox.create('{{name}}') + + # Your sandbox is ready to use! + print('Sandbox created successfully') + +# Run the async function +asyncio.run(main()) +``` +{{/if}} + +## Template Structure + +- `{{templateFile}}` - Defines the sandbox template configuration +- `{{buildDevFile}}` - Builds the template for development +- `{{buildProdFile}}` - Builds the template for production + +## Next Steps + +1. Customize the template in `{{templateFile}}` to fit your needs +2. Build the template using one of the methods above +3. Use the template in your E2B sandbox code +4. Check out the [E2B documentation](https://e2b.dev/docs) for more advanced usage diff --git a/packages/cli/src/templates/typescript-build.hbs b/packages/cli/src/templates/typescript-build.hbs new file mode 100644 index 0000000..1566af2 --- /dev/null +++ b/packages/cli/src/templates/typescript-build.hbs @@ -0,0 +1,16 @@ +import { Template, defaultBuildLogger } from 'e2b' +import { template } from './template' + +async function main() { + await Template.build(template, '{{name}}', { + {{#if cpuCount}} + cpuCount: {{cpuCount}}, + {{/if}} + {{#if memoryMB}} + memoryMB: {{memoryMB}}, + {{/if}} + onBuildLogs: defaultBuildLogger(), + }); +} + +main().catch(console.error); diff --git a/packages/cli/src/templates/typescript-template.hbs b/packages/cli/src/templates/typescript-template.hbs new file mode 100644 index 0000000..dabd190 --- /dev/null +++ b/packages/cli/src/templates/typescript-template.hbs @@ -0,0 +1,34 @@ +import { Template } from 'e2b' + +export const template = Template() +{{#if fromImage}} + .fromImage('{{{fromImage}}}') +{{/if}} +{{#each steps}} +{{#eq type "WORKDIR"}} + .setWorkdir('{{{args.[0]}}}') +{{/eq}} +{{#eq type "USER"}} + .setUser('{{{args.[0]}}}') +{{/eq}} +{{#eq type "ENV"}} + .setEnvs({ +{{#each envVars}} + '{{{@key}}}': '{{{this}}}', +{{/each}} + }) +{{/eq}} +{{#eq type "RUN"}} + .runCmd('{{{args.[0]}}}') +{{/eq}} +{{#eq type "COPY"}} + .copy('{{{src}}}', '{{{dest}}}') +{{/eq}} +{{/each}} +{{#if startCmd}} +{{#if readyCmd}} + .setStartCmd('sudo {{{escapeQuotes startCmd}}}', '{{{escapeQuotes readyCmd}}}') +{{/if}} +{{else if readyCmd}} + .setReadyCmd('sudo {{{escapeQuotes readyCmd}}}') +{{/if}} diff --git a/packages/cli/src/terminal.ts b/packages/cli/src/terminal.ts new file mode 100644 index 0000000..713314e --- /dev/null +++ b/packages/cli/src/terminal.ts @@ -0,0 +1,108 @@ +import * as e2b from 'e2b' + +const FLUSH_INPUT_INTERVAL_MS = 10 + +function getStdoutSize() { + return { + cols: process.stdout.columns, + rows: process.stdout.rows, + } +} + +export async function spawnConnectedTerminal(sandbox: e2b.Sandbox) { + // Clear local terminal emulator before starting terminal + // process.stdout.write('\x1b[2J\x1b[0f') + + process.stdin.setRawMode(true) + process.stdout.setEncoding('utf-8') + + const terminalSession = await sandbox.pty.create({ + onData: (data) => { + process.stdout.write(data) + }, + ...getStdoutSize(), + timeoutMs: 0, + }) + + const inputQueue = new BatchedQueue(async (batch) => { + const combined = Buffer.concat(batch) + await sandbox.pty.sendInput(terminalSession.pid, combined) + }, FLUSH_INPUT_INTERVAL_MS) + + const resizeListener = process.stdout.on('resize', () => + sandbox.pty.resize(terminalSession.pid, getStdoutSize()) + ) + const stdinListener = process.stdin.on('data', (data) => { + inputQueue.push(data) + }) + + inputQueue.start() + + // Wait for terminal session to finish + try { + await terminalSession.wait() + } catch (err: any) { + if (err instanceof e2b.CommandExitError) { + if (err.exitCode === -1 && err.error === 'signal: killed') { + return + } + if (err.exitCode === 130) { + console.warn('Terminal session was killed by user') + return + } + } + throw err + } finally { + // Cleanup + process.stdout.write('\n') + resizeListener.destroy() + stdinListener.destroy() + await inputQueue.stop() + process.stdin.setRawMode(false) + } +} + +class BatchedQueue { + private queue: T[] = [] + private isFlushing = false + private intervalId?: NodeJS.Timeout + + constructor( + private flushHandler: (batch: T[]) => Promise, + private flushIntervalMs: number + ) {} + + push(item: T) { + this.queue.push(item) + } + + start() { + this.intervalId = setInterval(async () => { + if (this.isFlushing) return + + this.isFlushing = true + await this.flush() + this.isFlushing = false + }, this.flushIntervalMs) + } + + async stop() { + if (this.intervalId) { + clearInterval(this.intervalId) + this.intervalId = undefined + } + + await this.flush() + } + + private async flush() { + if (this.queue.length === 0) return + + const batch = this.queue.splice(0, this.queue.length) + try { + await this.flushHandler(batch) + } catch (err) { + console.error('Error sending input:', err) + } + } +} diff --git a/packages/cli/src/user.ts b/packages/cli/src/user.ts new file mode 100644 index 0000000..f3f7151 --- /dev/null +++ b/packages/cli/src/user.ts @@ -0,0 +1,105 @@ +import * as os from 'os' +import * as path from 'path' +import * as fs from 'fs' + +/** + * User configuration stored in ~/.e2b/config.json + */ +export interface UserIdentity { + email: string +} + +export interface UserOAuth { + token_endpoint: string + revoke_endpoint: string + client_id: string +} + +export interface UserTokens { + access_token: string + refresh_token: string +} + +export interface UserConfig { + version: 1 + identity: UserIdentity + oauth: UserOAuth + tokens: UserTokens + last_refresh: string + teamName: string + teamId: string + teamApiKey: string + dockerProxySet?: boolean +} + +type UnknownRecord = Record + +export const USER_CONFIG_PATH = path.join(os.homedir(), '.e2b', 'config.json') // TODO: Keep in Keychain + +export const DEPRECATED_USER_CONFIG_MESSAGE = + 'Your CLI authentication config is deprecated. You have been signed out. Please run `e2b auth login` again.' + +export const DOCS_BASE = + process.env.E2B_DOCS_BASE || + `https://${process.env.E2B_DOMAIN || 'e2b.dev'}/docs` + +export const DASHBOARD_BASE = + process.env.E2B_DASHBOARD_BASE || + `https://${process.env.E2B_DOMAIN || 'e2b.dev'}/dashboard` + +export const SANDBOX_INSPECT_URL = (sandboxId: string) => + `${DASHBOARD_BASE}/inspect/sandbox/${sandboxId}` + +export function getUserConfig(): UserConfig | null { + if (!fs.existsSync(USER_CONFIG_PATH)) return null + const config = JSON.parse(fs.readFileSync(USER_CONFIG_PATH, 'utf8')) + + if (!isUserConfig(config)) { + fs.unlinkSync(USER_CONFIG_PATH) + console.error(DEPRECATED_USER_CONFIG_MESSAGE) + return null + } + + return config +} + +function isObject(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isString(value: unknown): value is string { + return typeof value === 'string' +} + +function isUserConfig(config: unknown): config is UserConfig { + if (!isObject(config)) return false + if (config.version !== 1) return false + return ( + isObject(config.identity) && + isString(config.identity.email) && + isObject(config.oauth) && + isString(config.oauth.token_endpoint) && + isString(config.oauth.revoke_endpoint) && + isString(config.oauth.client_id) && + isObject(config.tokens) && + isString(config.tokens.access_token) && + isString(config.tokens.refresh_token) + ) +} + +export function getConfigRefreshTimestamp(): string { + return new Date().toISOString() +} + +/** + * Write user config to disk with restrictive file permissions. + * The config directory is restricted to the owner and the config file is + * written as owner-readable/writable only because it contains credentials. + */ +export function writeUserConfig(configPath: string, config: UserConfig): void { + const dir = path.dirname(configPath) + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) + fs.chmodSync(dir, 0o700) + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 }) + fs.chmodSync(configPath, 0o600) +} diff --git a/packages/cli/src/utils/commands2md.ts b/packages/cli/src/utils/commands2md.ts new file mode 100644 index 0000000..966a8a0 --- /dev/null +++ b/packages/cli/src/utils/commands2md.ts @@ -0,0 +1,85 @@ +import { Command } from 'commander' +import fs from 'fs' +import json2md from 'json2md' +import path from 'path' + +/** + * Converts command objects to Markdown documentation. + * This function takes an array of command objects and generates a structured + * Markdown document describing each command, its usage, options, and subcommands. + * @returns A string containing the entire markdown documentation for all commands. + */ +export function commands2md(commands: Command[]): void { + const outputDir = 'sdk_ref' + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }) + } + + function commandToMd( + command: any, + parentName: string = '' + ): [string, string] { + const commandName = command.name() as string + const fullName = parentName ? `${parentName} ${commandName}` : commandName + + const mdStructure = [ + { h2: `e2b ${fullName}` }, + { p: command.description() }, + { h3: 'Usage' }, + { + code: { + language: 'bash', + content: `e2b ${fullName} ${command.usage()}`, + }, + }, + ...(command.options.length > 0 + ? [ + { h3: 'Options' }, + { + ul: command.options.map( + (y: any) => + `\`${y.flags}: ${y.description} ${ + y.defaultValue !== undefined + ? `[default: ${y.defaultValue}]` + : '' + }\`` + ), + }, + ] + : []), + ] + + let mdContent = json2md(mdStructure) + + // Process subcommands + command.commands.forEach((subcommand: any) => { + const [, subMdContent] = commandToMd(subcommand, fullName) + mdContent += subMdContent + '\n\n' + }) + + // Clean the mdContent from terminal colors and escape HTML characters + mdContent = mdContent + .replace(//g, '>') + .replace(/\[1m/g, '') + .replace(/\[22m/g, '') + .replace(/\[34m/g, '') + .replace(/\[39m/g, '') + .replace(/\[38;2;255;183;102m/g, '') + + return [fullName, mdContent] + } + + commands.forEach((command: any) => { + try { + const [commandName, mdContent] = commandToMd(command) + const fileName = `${commandName}.md` + const filePath = path.join(outputDir, fileName) + fs.writeFileSync(filePath, mdContent) + console.log(`Generated documentation for ${commandName} at ${filePath}`) + } catch (error) { + console.error(`Error processing command: ${command.name()}`) + console.error(error) + } + }) +} diff --git a/packages/cli/src/utils/confirm.ts b/packages/cli/src/utils/confirm.ts new file mode 100644 index 0000000..729d453 --- /dev/null +++ b/packages/cli/src/utils/confirm.ts @@ -0,0 +1,13 @@ +export async function confirm(text: string, defaultAnswer = false) { + const inquirer = await import('inquirer') + const confirmAnswers = await inquirer.default.prompt([ + { + name: 'confirm', + type: 'confirm', + default: defaultAnswer, + message: text, + }, + ]) + + return confirmAnswers['confirm'] as boolean +} diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts new file mode 100644 index 0000000..f59a522 --- /dev/null +++ b/packages/cli/src/utils/errors.ts @@ -0,0 +1,72 @@ +import status from 'statuses' + +/** + * Thrown when a request to E2B API occurs. + */ +export class E2BRequestError extends Error { + constructor(message: any) { + super(message) + this.name = 'E2BRequestError' + } +} + +type E2BResponseError = { code?: number; message?: string } + +type E2BResponse = + | { + data: TData + error?: undefined + } + | { + data?: undefined + error: E2BResponseError + } + +function throwE2BRequestError(error: E2BResponseError, errMsg?: string): never { + let message: string + const code = error.code ?? 0 + switch (code) { + case 400: + message = 'bad request' + break + case 401: + message = 'unauthorized' + break + case 403: + message = 'forbidden' + break + case 404: + message = 'not found' + break + case 500: + message = 'internal server error' + break + default: + message = status.message[code] || 'unknown error' + break + } + + throw new E2BRequestError( + `${errMsg && `${errMsg}: `}[${code}] ${message && `${message}: `}${ + error.message ?? 'no message' + }` + ) +} + +export function handleE2BRequestError( + res: { error: E2BResponseError }, + errMsg?: string +): never +export function handleE2BRequestError( + res: E2BResponse, + errMsg?: string +): asserts res is { data: TData; error?: undefined } +export function handleE2BRequestError( + res: E2BResponse, + errMsg?: string +) { + if (!res.error) { + return + } + throwE2BRequestError(res.error, errMsg) +} diff --git a/packages/cli/src/utils/filesystem.ts b/packages/cli/src/utils/filesystem.ts new file mode 100644 index 0000000..cd0b413 --- /dev/null +++ b/packages/cli/src/utils/filesystem.ts @@ -0,0 +1,12 @@ +import * as path from 'path' + +export function getRoot(templatePath?: string) { + const defaultPath = process.cwd() + if (!templatePath) return defaultPath + if (path.isAbsolute(templatePath)) return templatePath + return path.resolve(defaultPath, templatePath) +} + +export function cwdRelative(absolutePath: string) { + return path.relative(process.cwd(), absolutePath) +} diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts new file mode 100644 index 0000000..8476903 --- /dev/null +++ b/packages/cli/src/utils/format.ts @@ -0,0 +1,150 @@ +import * as chalk from 'chalk' +import * as e2b from 'e2b' +import * as highlight from 'cli-highlight' +import * as boxen from 'boxen' + +import { cwdRelative } from './filesystem' +import { UserConfig } from '../user' + +export const primaryColor = '#FFB766' + +export function asFormattedConfig(config: UserConfig) { + const email = asBold(config.identity.email) + const team = config.teamName + ? asBold(config.teamName) + : asRed('Log out and log in to get team name') + const teamId = asBold(config.teamId) + return `You are logged in as ${email},\nSelected team: ${team} (${teamId})` +} + +export function asFormattedTeam( + team: e2b.components['schemas']['Team'], + selected: string +) { + const name = asBold(team.name) + const id = asBold(team.teamID) + const isSelected = + team.teamID == selected ? asPrimary(' (currently selected team)') : '' + return `${name} (${id})${isSelected}` +} + +export function asFormattedSandboxTemplate( + template: Pick & { + aliases?: e2b.components['schemas']['Template']['aliases'] + }, + configLocalPath?: string +) { + const aliases = listAliases(template.aliases) + + const name = aliases ? asBold(aliases) : '' + const configPath = configLocalPath + ? asDim(' <-> ') + asLocalRelative(configLocalPath) + : '' + + const id = `${template.templateID} ` + + return `${id}${name}${configPath}`.trim() +} + +export function asRed(text: string) { + return chalk.default.redBright(text) +} + +export function asFormattedError(text: string | undefined, err?: any) { + return chalk.default.redBright( + `${text ? `${text} \n` : ''}${err ? err.stack : ''}\n` + ) +} + +export function asDim(content?: string) { + return chalk.default.dim(content) +} + +export function asBold(content: string) { + return chalk.default.bold(content) +} + +export function asPrimary(content: string) { + return chalk.default.hex(primaryColor)(content) +} + +export function asTimestamp(content: string) { + return chalk.default.blue(content) +} + +export function asLocal(pathInLocal?: string) { + return chalk.default.blue(pathInLocal) +} + +export function asLocalRelative(absolutePathInLocal?: string) { + if (!absolutePathInLocal) return '' + return asLocal('./' + cwdRelative(absolutePathInLocal)) +} + +export function asBuildLogs(content: string) { + return chalk.default.blueBright(content) +} + +export function withUnderline(content: string) { + return chalk.default.underline(content) +} + +export function listAliases(aliases: string[] | undefined) { + if (!aliases) return undefined + return aliases.join(', ') +} + +export function asTypescript(code: string) { + return highlight.default(code, { + language: 'typescript', + ignoreIllegals: true, + }) +} + +export function asPython(code: string) { + return highlight.default(code, { language: 'python', ignoreIllegals: true }) +} + +export const borderStyle = { + topLeft: '', + topRight: '', + bottomLeft: '', + bottomRight: '', + top: '', + bottom: '', + left: '', + right: '', +} as const + +const horizontalPadding = 2 +const verticalPadding = 1 + +export function withDelimiter( + content: string, + title: string, + isLast?: boolean +) { + return boxen.default(content, { + borderStyle: { + ...borderStyle, + top: '─', + bottom: isLast ? '─' : '', + }, + titleAlignment: 'center', + float: 'left', + title: title ? asBold(title) : undefined, + margin: { + top: 0, + bottom: 0, + left: 1, + right: 0, + }, + fullscreen: (w) => [w, 0], + padding: { + bottom: isLast ? verticalPadding : 0, + left: horizontalPadding, + right: horizontalPadding, + top: verticalPadding, + }, + }) +} diff --git a/packages/cli/src/utils/openBrowser.ts b/packages/cli/src/utils/openBrowser.ts new file mode 100644 index 0000000..fe981d5 --- /dev/null +++ b/packages/cli/src/utils/openBrowser.ts @@ -0,0 +1,29 @@ +import { spawn } from 'child_process' + +// Spawn the platform's URL opener ourselves so the 'error' listener is attached +// synchronously. The `open` package (v9.x) only attaches its listener after a +// microtask, by which point a `spawn` ENOENT (e.g. missing `xdg-open` on +// headless Linux) has already been emitted and crashes the process — see +// sindresorhus/open#144. +export function openUrlInBrowser(url: string, onError: () => void): void { + let command: string + let args: string[] + if (process.platform === 'darwin') { + command = 'open' + args = [url] + } else if (process.platform === 'win32') { + command = 'cmd' + args = ['/c', 'start', '""', url.replace(/&/g, '^&')] + } else { + command = 'xdg-open' + args = [url] + } + + try { + const child = spawn(command, args, { stdio: 'ignore', detached: true }) + child.once('error', onError) + child.unref() + } catch { + onError() + } +} diff --git a/packages/cli/src/utils/signal.ts b/packages/cli/src/utils/signal.ts new file mode 100644 index 0000000..5683998 --- /dev/null +++ b/packages/cli/src/utils/signal.ts @@ -0,0 +1,16 @@ +import * as os from 'os' + +// Signals we handle - filtered to those defined by the OS. +// Note: SIGKILL and SIGSTOP cannot be caught. +const HANDLED_SIGNALS = ( + ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT', 'SIGABRT', 'SIGPIPE'] as const +).filter((sig) => sig in os.constants.signals) + +export function setupSignalHandlers( + onSignal: NodeJS.SignalsListener +): () => void { + HANDLED_SIGNALS.forEach((sig) => process.on(sig, onSignal)) + + return () => + HANDLED_SIGNALS.forEach((sig) => process.removeListener(sig, onSignal)) +} diff --git a/packages/cli/src/utils/templateName.ts b/packages/cli/src/utils/templateName.ts new file mode 100644 index 0000000..a418ea9 --- /dev/null +++ b/packages/cli/src/utils/templateName.ts @@ -0,0 +1,30 @@ +/** + * Allowed template name (alias) format, matching the server-side validation in + * e2b-dev/infra (`id.identifierRegex`): the name is trimmed and lowercased, + * then must contain only lowercase letters, numbers, dashes and underscores. + */ +const templateNameRegex = /^[a-z0-9-_]+$/ + +const MAX_TEMPLATE_NAME_LENGTH = 128 + +/** + * Validates a template name and returns its normalized form (trimmed and + * lowercased), matching how the server normalizes it. + */ +export function validateTemplateName(name: string): string { + const cleaned = name?.trim().toLowerCase() + if (!cleaned) { + throw new Error('Template name cannot be empty') + } + if (!templateNameRegex.test(cleaned)) { + throw new Error( + 'Template name must contain only letters, numbers, dashes and underscores' + ) + } + if (cleaned.length > MAX_TEMPLATE_NAME_LENGTH) { + throw new Error( + `Template name must be at most ${MAX_TEMPLATE_NAME_LENGTH} characters long` + ) + } + return cleaned +} diff --git a/packages/cli/src/utils/templatePrompt.ts b/packages/cli/src/utils/templatePrompt.ts new file mode 100644 index 0000000..428a307 --- /dev/null +++ b/packages/cli/src/utils/templatePrompt.ts @@ -0,0 +1,27 @@ +import * as e2b from 'e2b' +import * as chalk from 'chalk' + +import { asFormattedSandboxTemplate } from 'src/utils/format' + +export async function getPromptTemplates( + templates: e2b.components['schemas']['Template'][], + text: string +) { + const inquirer = await import('inquirer') + const templatesAnswers = await inquirer.default.prompt([ + { + name: 'templates', + message: chalk.default.underline(text), + type: 'checkbox', + pageSize: 50, + choices: templates.map((e) => ({ + name: asFormattedSandboxTemplate(e), + value: e, + })), + }, + ]) + + return templatesAnswers[ + 'templates' + ] as e2b.components['schemas']['Template'][] +} diff --git a/packages/cli/src/utils/templateSort.ts b/packages/cli/src/utils/templateSort.ts new file mode 100644 index 0000000..17dfd2f --- /dev/null +++ b/packages/cli/src/utils/templateSort.ts @@ -0,0 +1,7 @@ +import * as sdk from 'e2b' + +export function sortTemplatesAliases< + E extends sdk.components['schemas']['Template']['aliases'], +>(aliases: E) { + aliases?.sort() +} diff --git a/packages/cli/src/utils/token-refresh.ts b/packages/cli/src/utils/token-refresh.ts new file mode 100644 index 0000000..bf49bcd --- /dev/null +++ b/packages/cli/src/utils/token-refresh.ts @@ -0,0 +1,114 @@ +import * as fs from 'fs' + +import { getUserConfig, writeUserConfig, USER_CONFIG_PATH } from 'src/user' + +const REFRESH_SKEW_SECONDS = 60 + +function decodeJwtExp(token: string): number | undefined { + const payload = token.split('.')[1] + if (!payload) return undefined + try { + const decoded = JSON.parse( + Buffer.from(payload, 'base64url').toString('utf8') + ) as { exp?: number } + return decoded.exp + } catch { + return undefined + } +} + +export function isTokenExpired(accessToken: string): boolean { + const exp = decodeJwtExp(accessToken) + if (!exp) return true + return Math.floor(Date.now() / 1000) >= exp - REFRESH_SKEW_SECONDS +} + +export class TokenRefreshError extends Error { + constructor( + public status: number, + public errorCode: string, + message: string + ) { + super(message) + this.name = 'TokenRefreshError' + } +} + +type RefreshedTokens = { + access_token: string + refresh_token: string +} + +export async function refreshOAuthToken( + refreshToken: string, + clientId: string, + tokenEndpoint: string +): Promise { + const res = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: clientId, + }), + }) + + if (!res.ok) { + const body = await res.json().catch(() => ({}) as Record) + throw new TokenRefreshError( + res.status, + (body.error as string) ?? 'unknown', + (body.error_description as string) ?? 'Token refresh failed' + ) + } + + const data = (await res.json()) as { + access_token: string + refresh_token?: string + expires_in: number + } + + return { + access_token: data.access_token, + refresh_token: data.refresh_token ?? refreshToken, + } +} + +export async function ensureValidAccessToken(): Promise { + const config = getUserConfig() + if (!config) { + throw new Error('No user config found, run `e2b auth login` first.') + } + + if (!isTokenExpired(config.tokens.access_token)) { + return config.tokens.access_token + } + + try { + const refreshed = await refreshOAuthToken( + config.tokens.refresh_token, + config.oauth.client_id, + config.oauth.token_endpoint + ) + + // Token refresh updates only tokens.* — it does NOT update last_refresh. + writeUserConfig(USER_CONFIG_PATH, { + ...config, + tokens: { + access_token: refreshed.access_token, + refresh_token: refreshed.refresh_token, + }, + }) + + return refreshed.access_token + } catch (err) { + if (err instanceof TokenRefreshError && err.errorCode === 'invalid_grant') { + fs.rmSync(USER_CONFIG_PATH, { force: true }) + throw new Error( + 'Your session has expired. Please run `e2b auth login` again.' + ) + } + throw err + } +} diff --git a/packages/cli/src/utils/urls.ts b/packages/cli/src/utils/urls.ts new file mode 100644 index 0000000..083099f --- /dev/null +++ b/packages/cli/src/utils/urls.ts @@ -0,0 +1,23 @@ +import { SANDBOX_INSPECT_URL } from 'src/user' +import { asPrimary } from './format' + +/** + * Prints a clickable URL to the E2B Dashboard for inspecting a sandbox + * + * This function creates a terminal-clickable link that allows users to + * inspect their sandbox in the E2B Dashboard. The link is formatted with + * ANSI escape sequences to make it clickable in compatible terminals. + * + * @param {string} sandboxId - The ID of the sandbox to inspect + */ +export const printDashboardSandboxInspectUrl = (sandboxId: string) => { + const url = SANDBOX_INSPECT_URL(sandboxId) + const clickable = `\u001b]8;;${url}\u0007${url}\u001b]8;;\u0007` + + console.log('') + console.log( + 'Use the following link to inspect this Sandbox live inside the E2B Dashboard️:' + ) + console.log(asPrimary(`↪ ${clickable}`)) + console.log('') +} diff --git a/packages/cli/src/utils/wait.ts b/packages/cli/src/utils/wait.ts new file mode 100644 index 0000000..dd7e92f --- /dev/null +++ b/packages/cli/src/utils/wait.ts @@ -0,0 +1,3 @@ +export async function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/packages/cli/testground/demo-basic/.dockerignore b/packages/cli/testground/demo-basic/.dockerignore new file mode 100644 index 0000000..ef4f590 --- /dev/null +++ b/packages/cli/testground/demo-basic/.dockerignore @@ -0,0 +1 @@ +assets-to-be-ignored diff --git a/packages/cli/testground/demo-basic/.gitignore b/packages/cli/testground/demo-basic/.gitignore new file mode 100644 index 0000000..69c5002 --- /dev/null +++ b/packages/cli/testground/demo-basic/.gitignore @@ -0,0 +1,2 @@ +assets-to-be-ignored +*.tar.gz diff --git a/packages/cli/testground/demo-basic/Dockerfile b/packages/cli/testground/demo-basic/Dockerfile new file mode 100644 index 0000000..2a0eb1a --- /dev/null +++ b/packages/cli/testground/demo-basic/Dockerfile @@ -0,0 +1,4 @@ +FROM ubuntu:latest +LABEL authors="i" + +ENTRYPOINT ["top", "-b"] \ No newline at end of file diff --git a/packages/cli/testground/demo-basic/main.mjs b/packages/cli/testground/demo-basic/main.mjs new file mode 100644 index 0000000..bc09131 --- /dev/null +++ b/packages/cli/testground/demo-basic/main.mjs @@ -0,0 +1,4 @@ +// Console log current time with date-fns +import { format } from 'date-fns' + +console.log(`Current time is ${format(new Date(), 'HH:mm:ss')}`) diff --git a/packages/cli/testground/demo-basic/package.json b/packages/cli/testground/demo-basic/package.json new file mode 100644 index 0000000..419ad01 --- /dev/null +++ b/packages/cli/testground/demo-basic/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "date-fns": "^2.30.0" + } +} diff --git a/packages/cli/tests/commands/sandbox/backend_integration.test.ts b/packages/cli/tests/commands/sandbox/backend_integration.test.ts new file mode 100644 index 0000000..13ab308 --- /dev/null +++ b/packages/cli/tests/commands/sandbox/backend_integration.test.ts @@ -0,0 +1,207 @@ +import { afterAll, beforeAll, describe, expect, test } from 'vitest' +import { Sandbox } from 'e2b' +import { getUserConfig } from 'src/user' +import { + bufferToText, + isDebug, + parseEnvInt, + runCli, + runCliWithPipedStdin, +} from '../../setup' + +type UserConfigWithDomain = NonNullable> & { + domain?: string + E2B_DOMAIN?: string +} + +const userConfig = safeGetUserConfig() as UserConfigWithDomain | null +const domain = + process.env.E2B_DOMAIN || + userConfig?.E2B_DOMAIN || + userConfig?.domain || + 'e2b.app' +const apiKey = process.env.E2B_API_KEY || userConfig?.teamApiKey +const shouldSkip = !apiKey || isDebug +const integrationTest = test.skipIf(shouldSkip) +const templateId = + process.env.E2B_CLI_BACKEND_TEMPLATE_ID || + process.env.E2B_TEMPLATE_ID || + 'base' +const sandboxTimeoutMs = parseEnvInt( + 'E2B_CLI_BACKEND_SANDBOX_TIMEOUT_MS', + 20_000 +) +const perTestTimeoutMs = parseEnvInt('E2B_CLI_BACKEND_TEST_TIMEOUT_MS', 30_000) +const spawnTimeoutMs = perTestTimeoutMs +const cliEnv: NodeJS.ProcessEnv = { + ...process.env, + E2B_DOMAIN: domain, + E2B_API_KEY: apiKey, +} + +delete cliEnv.E2B_DEBUG + +const runCliInSandbox = (args: string[]) => + runCli(args, { timeoutMs: spawnTimeoutMs, env: cliEnv }) +const runCliWithPipeInSandbox = (args: string[], input: Buffer) => + runCliWithPipedStdin(args, input, { timeoutMs: spawnTimeoutMs, env: cliEnv }) + +describe('sandbox cli backend integration', () => { + let sandbox: Sandbox + + beforeAll(async () => { + if (shouldSkip) return + + sandbox = await Sandbox.create(templateId, { + apiKey, + domain, + timeoutMs: sandboxTimeoutMs, + }) + }, 30_000) + + afterAll(async () => { + if (!sandbox) return + + try { + await sandbox.kill() + } catch (err) { + console.warn( + `Failed to kill sandbox ${sandbox.sandboxId} in cleanup: ${String(err)}` + ) + } + }, 15_000) + + integrationTest( + 'list shows the sandbox', + { timeout: perTestTimeoutMs }, + async () => { + const listResult = runCliInSandbox(['sandbox', 'list', '--format', 'json']) + expect(listResult.status).toBe(0) + expect(sandboxExistsInList(listResult.stdout, sandbox.sandboxId)).toBe(true) + } + ) + + integrationTest( + 'info shows the sandbox details', + { timeout: perTestTimeoutMs }, + async () => { + const infoResult = runCliInSandbox([ + 'sandbox', + 'info', + sandbox.sandboxId, + '--format', + 'json', + ]) + expect(infoResult.status).toBe(0) + + const info = JSON.parse(bufferToText(infoResult.stdout)) as { + sandboxId?: string + state?: string + } + + expect(info.sandboxId).toBe(sandbox.sandboxId) + expect(info.state).toBe('running') + } + ) + + integrationTest( + 'exec runs a command without piped stdin', + { timeout: perTestTimeoutMs }, + async () => { + const execResult = runCliInSandbox([ + 'sandbox', + 'exec', + sandbox.sandboxId, + '--', + 'sh', + '-lc', + 'echo backend-non-pipe', + ]) + expect(execResult.status).toBe(0) + expect(bufferToText(execResult.stdout)).toContain('backend-non-pipe') + } + ) + + integrationTest( + 'exec runs a command with piped stdin', + { timeout: perTestTimeoutMs }, + async () => { + const pipedExecResult = await runCliWithPipeInSandbox( + ['sandbox', 'exec', sandbox.sandboxId, '--', 'sh', '-lc', 'wc -c'], + Buffer.from('hello\n', 'utf8') + ) + expect(pipedExecResult.status).toBe(0) + const pipedStdout = bufferToText(pipedExecResult.stdout).trim() + if (pipedStdout !== '6') { + expect(pipedStdout).toBe('0') + } + } + ) + + integrationTest( + 'metrics returns successfully', + { timeout: perTestTimeoutMs }, + async () => { + const metricsResult = runCliInSandbox([ + 'sandbox', + 'metrics', + sandbox.sandboxId, + '--format', + 'json', + ]) + expect(metricsResult.status).toBe(0) + } + ) + + integrationTest( + 'kill removes the sandbox', + { timeout: perTestTimeoutMs }, + async () => { + const killResult = runCliInSandbox(['sandbox', 'kill', sandbox.sandboxId]) + expect(killResult.status).toBe(0) + + await assertSandboxNotListed(sandbox.sandboxId) + } + ) +}) + +async function assertSandboxNotListed(sandboxId: string): Promise { + const retries = 10 + const delayMs = 500 + + for (let i = 0; i < retries; i++) { + const listResult = runCliInSandbox(['sandbox', 'list', '--format', 'json']) + if (listResult.status === 0) { + const exists = sandboxExistsInList(listResult.stdout, sandboxId) + if (!exists) { + return + } + } + + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + + throw new Error(`Sandbox ${sandboxId} still appears in sandbox list`) +} + +function sandboxExistsInList( + output: string | Buffer | null | undefined, + sandboxId: string +): boolean { + const text = bufferToText(output).trim() + if (!text) { + return false + } + + const parsed = JSON.parse(text) as Array<{ sandboxId?: string }> + return parsed.some((item) => item.sandboxId === sandboxId) +} + +function safeGetUserConfig(): ReturnType | null { + try { + return getUserConfig() + } catch (err) { + console.warn(`Failed to read ~/.e2b/config.json: ${String(err)}`) + return null + } +} diff --git a/packages/cli/tests/commands/sandbox/create_lifecycle.test.ts b/packages/cli/tests/commands/sandbox/create_lifecycle.test.ts new file mode 100644 index 0000000..d4200f0 --- /dev/null +++ b/packages/cli/tests/commands/sandbox/create_lifecycle.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const create = vi.fn() + const ensureAPIKey = vi.fn(() => 'test-api-key') + const spawnConnectedTerminal = vi.fn() + + return { + create, + ensureAPIKey, + spawnConnectedTerminal, + } +}) + +vi.mock('e2b', () => ({ + Sandbox: { + create: mocks.create, + }, +})) + +vi.mock('../../../src/api', () => ({ + ensureAPIKey: mocks.ensureAPIKey, +})) + +vi.mock('src/utils/urls', () => ({ + printDashboardSandboxInspectUrl: vi.fn(), +})) + +vi.mock('src/terminal', () => ({ + spawnConnectedTerminal: mocks.spawnConnectedTerminal, +})) + +describe('sandbox create lifecycle options', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + + mocks.create.mockResolvedValue({ + sandboxId: 'sandbox-id', + setTimeout: vi.fn().mockResolvedValue(undefined), + }) + mocks.spawnConnectedTerminal.mockResolvedValue(undefined) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + test('passes ontimeout and autoresume to Sandbox.create', async () => { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) + + const { createCommand } = await import( + '../../../src/commands/sandbox/create' + ) + await createCommand('create', 'cr', false).parseAsync( + [ + 'base', + '--detach', + '--lifecycle.ontimeout', + 'pause', + '--lifecycle.autoresume', + ], + { from: 'user' } + ) + + expect(mocks.create).toHaveBeenCalledWith('base', { + apiKey: 'test-api-key', + lifecycle: { + onTimeout: 'pause', + autoResume: true, + }, + }) + expect(exitSpy).toHaveBeenCalledWith(0) + }) + + test('passes ontimeout without autoresume to Sandbox.create', async () => { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) + + const { createCommand } = await import( + '../../../src/commands/sandbox/create' + ) + await createCommand('create', 'cr', false).parseAsync( + ['base', '--detach', '--lifecycle.ontimeout', 'kill'], + { from: 'user' } + ) + + expect(mocks.create).toHaveBeenCalledWith('base', { + apiKey: 'test-api-key', + lifecycle: { + onTimeout: 'kill', + }, + }) + expect(exitSpy).toHaveBeenCalledWith(0) + }) + + test('passes timeout seconds to Sandbox.create as milliseconds', async () => { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) + + const { createCommand } = await import( + '../../../src/commands/sandbox/create' + ) + await createCommand('create', 'cr', false).parseAsync( + ['base', '--detach', '--timeout', '120'], + { from: 'user' } + ) + + expect(mocks.create).toHaveBeenCalledWith('base', { + apiKey: 'test-api-key', + timeoutMs: 120_000, + }) + expect(exitSpy).toHaveBeenCalledWith(0) + }) + + test('preserves explicit timeout after attached terminal closes', async () => { + vi.useFakeTimers() + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) + const sandbox = { + sandboxId: 'sandbox-id', + setTimeout: vi.fn().mockResolvedValue(undefined), + } + mocks.create.mockResolvedValue(sandbox) + + const { createCommand } = await import( + '../../../src/commands/sandbox/create' + ) + await createCommand('create', 'cr', false).parseAsync( + ['base', '--timeout', '120'], + { from: 'user' } + ) + + expect(mocks.spawnConnectedTerminal).toHaveBeenCalledWith(sandbox) + expect(sandbox.setTimeout).toHaveBeenCalledWith(120_000) + expect(sandbox.setTimeout).not.toHaveBeenCalledWith(1_000) + expect(exitSpy).toHaveBeenCalledWith(0) + vi.useRealTimers() + }) + + test('rejects autoresume without pause on timeout', async () => { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { createCommand } = await import( + '../../../src/commands/sandbox/create' + ) + await createCommand('create', 'cr', false).parseAsync( + [ + 'base', + '--detach', + '--lifecycle.ontimeout', + 'kill', + '--lifecycle.autoresume', + ], + { from: 'user' } + ) + + expect(mocks.create).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: '--lifecycle.autoresume requires --lifecycle.ontimeout pause', + }) + ) + expect(exitSpy).toHaveBeenCalledWith(1) + }) + + test('rejects invalid ontimeout option', async () => { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) + + const { createCommand } = await import( + '../../../src/commands/sandbox/create' + ) + await expect( + createCommand('create', 'cr', false).parseAsync( + ['base', '--detach', '--lifecycle.ontimeout', 'hibernate'], + { from: 'user' } + ) + ).rejects.toThrow('--lifecycle.ontimeout must be "pause" or "kill"') + + expect(mocks.create).not.toHaveBeenCalled() + expect(exitSpy).toHaveBeenCalledWith(1) + }) + + test('rejects zero timeout before creating sandbox', async () => { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) + + const { createCommand } = await import( + '../../../src/commands/sandbox/create' + ) + await expect( + createCommand('create', 'cr', false).parseAsync( + ['base', '--detach', '--timeout', '0'], + { from: 'user' } + ) + ).rejects.toThrow('--timeout must be at least 30 seconds') + + expect(mocks.create).not.toHaveBeenCalled() + expect(exitSpy).toHaveBeenCalledWith(1) + }) + + test('rejects timeout values shorter than the keep-alive interval', async () => { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) + + const { createCommand } = await import( + '../../../src/commands/sandbox/create' + ) + await expect( + createCommand('create', 'cr', false).parseAsync( + ['base', '--detach', '--timeout', '29.999'], + { from: 'user' } + ) + ).rejects.toThrow('--timeout must be at least 30 seconds') + + expect(mocks.create).not.toHaveBeenCalled() + expect(exitSpy).toHaveBeenCalledWith(1) + }) + + test('passes lifecycle and timeout together to Sandbox.create', async () => { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) + + const { createCommand } = await import( + '../../../src/commands/sandbox/create' + ) + await createCommand('create', 'cr', false).parseAsync( + [ + 'base', + '--detach', + '--timeout', + '120', + '--lifecycle.ontimeout', + 'pause', + '--lifecycle.autoresume', + ], + { from: 'user' } + ) + + expect(mocks.create).toHaveBeenCalledWith('base', { + apiKey: 'test-api-key', + lifecycle: { + onTimeout: 'pause', + autoResume: true, + }, + timeoutMs: 120_000, + }) + expect(exitSpy).toHaveBeenCalledWith(0) + }) +}) diff --git a/packages/cli/tests/commands/sandbox/exec_close_stdin.test.ts b/packages/cli/tests/commands/sandbox/exec_close_stdin.test.ts new file mode 100644 index 0000000..17712e1 --- /dev/null +++ b/packages/cli/tests/commands/sandbox/exec_close_stdin.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const connect = vi.fn() + const run = vi.fn() + const wait = vi.fn() + const sendStdin = vi.fn() + const closeStdin = vi.fn() + const kill = vi.fn() + const ensureAPIKey = vi.fn(() => 'test-api-key') + const isPipedStdin = vi.fn() + const streamStdinChunks = vi.fn() + const setupSignalHandlers = vi.fn(() => () => {}) + + return { + connect, + run, + wait, + sendStdin, + closeStdin, + kill, + ensureAPIKey, + isPipedStdin, + streamStdinChunks, + setupSignalHandlers, + } +}) + +vi.mock('e2b', () => { + class CommandExitError extends Error { + exitCode: number + constructor(exitCode: number) { + super(`Command exited with ${exitCode}`) + this.exitCode = exitCode + } + } + + class NotFoundError extends Error {} + + return { + Sandbox: { + connect: mocks.connect, + }, + CommandExitError, + NotFoundError, + } +}) + +vi.mock('../../../src/api', () => ({ + ensureAPIKey: mocks.ensureAPIKey, +})) + +vi.mock('src/utils/signal', () => ({ + setupSignalHandlers: mocks.setupSignalHandlers, +})) + +vi.mock( + '../../../src/commands/sandbox/exec_helpers', + async (importOriginal: () => Promise) => { + const actual = + await importOriginal< + typeof import('../../../src/commands/sandbox/exec_helpers') + >() + return { + ...actual, + isPipedStdin: mocks.isPipedStdin, + streamStdinChunks: mocks.streamStdinChunks, + } + } +) + +describe('sandbox exec closeStdin handling', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + + mocks.wait.mockResolvedValue({ exitCode: 0 }) + const handle = { + pid: 1234, + error: undefined, + wait: mocks.wait, + kill: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + } + + mocks.run.mockResolvedValue(handle) + mocks.sendStdin.mockResolvedValue(undefined) + mocks.closeStdin.mockResolvedValue(undefined) + mocks.kill.mockResolvedValue(true) + mocks.isPipedStdin.mockReturnValue(true) + mocks.streamStdinChunks.mockImplementation( + async ( + _stream: NodeJS.ReadableStream, + onChunk: (chunk: Uint8Array) => Promise, + _maxBytes: number + ) => { + await onChunk(Buffer.from('hello')) + } + ) + mocks.connect.mockResolvedValue({ + commands: { + run: mocks.run, + sendStdin: mocks.sendStdin, + closeStdin: mocks.closeStdin, + kill: mocks.kill, + supportsStdinClose: true, + }, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + test('fails fast and kills remote process when closeStdin throws non-NotFoundError', async () => { + mocks.closeStdin.mockRejectedValue(new Error('close failed')) + + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { execCommand } = await import('../../../src/commands/sandbox/exec') + await execCommand.parseAsync(['sandbox-id', 'cat'], { + from: 'user', + }) + + expect(mocks.closeStdin).toHaveBeenCalledTimes(1) + expect(mocks.kill).toHaveBeenCalledWith(1234) + expect(mocks.wait).not.toHaveBeenCalled() + expect(exitSpy).toHaveBeenCalledWith(1) + }) + + test('keeps NotFoundError from closeStdin non-fatal', async () => { + const { NotFoundError } = await import('e2b') + mocks.closeStdin.mockRejectedValue(new NotFoundError('already exited')) + + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { execCommand } = await import('../../../src/commands/sandbox/exec') + await execCommand.parseAsync(['sandbox-id', 'cat'], { + from: 'user', + }) + + expect(mocks.closeStdin).toHaveBeenCalledTimes(1) + expect(mocks.kill).not.toHaveBeenCalled() + expect(mocks.wait).toHaveBeenCalledTimes(1) + expect(exitSpy).toHaveBeenCalledWith(0) + }) + + test('stops stdin streaming after NotFoundError from sendStdin', async () => { + const { NotFoundError } = await import('e2b') + mocks.sendStdin.mockRejectedValueOnce(new NotFoundError('already exited')) + mocks.streamStdinChunks.mockImplementation( + async ( + _stream: NodeJS.ReadableStream, + onChunk: (chunk: Uint8Array) => Promise, + _maxBytes: number + ) => { + const shouldContinue = await onChunk(Buffer.from('first')) + if (shouldContinue === false) { + return + } + await onChunk(Buffer.from('second')) + } + ) + + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { execCommand } = await import('../../../src/commands/sandbox/exec') + await execCommand.parseAsync(['sandbox-id', 'cat'], { + from: 'user', + }) + + expect(mocks.sendStdin).toHaveBeenCalledTimes(1) + expect(mocks.closeStdin).not.toHaveBeenCalled() + expect(mocks.wait).toHaveBeenCalledTimes(1) + expect(errorSpy).toHaveBeenCalledWith( + 'e2b: Remote command exited before stdin could be delivered.' + ) + expect(exitSpy).toHaveBeenCalledWith(0) + }) +}) diff --git a/packages/cli/tests/commands/sandbox/exec_helpers.test.ts b/packages/cli/tests/commands/sandbox/exec_helpers.test.ts new file mode 100644 index 0000000..6682b4a --- /dev/null +++ b/packages/cli/tests/commands/sandbox/exec_helpers.test.ts @@ -0,0 +1,226 @@ +import { PassThrough, Readable } from 'node:stream' +import { describe, expect, test } from 'vitest' + +import { + buildCommand, + chunkBytesBySize, + isPipedStdin, + readStdinIfPiped, + readStdinFrom, + streamStdinChunks, + shellQuote, +} from '../../../src/commands/sandbox/exec_helpers' + +describe('exec helpers', () => { + test('shellQuote leaves safe args untouched', () => { + expect(shellQuote('python3')).toBe('python3') + expect(shellQuote('-c')).toBe('-c') + expect(shellQuote('path/to/file.txt')).toBe('path/to/file.txt') + }) + + test('shellQuote wraps special chars and spaces', () => { + expect(shellQuote('print(input())')).toBe("'print(input())'") + expect(shellQuote('hello world')).toBe("'hello world'") + expect(shellQuote("it's ok")).toBe("'it'\"'\"'s ok'") + expect(shellQuote('')).toBe("''") + }) + + test('buildCommand returns a single command as-is', () => { + const cmd = 'python3 -c "print(input())"' + expect(buildCommand([cmd])).toBe(cmd) + }) + + test('buildCommand quotes args that need shell escaping', () => { + expect(buildCommand(['python3', '-c', 'print(input())'])).toBe( + "python3 -c 'print(input())'" + ) + expect(buildCommand(['echo', 'hello world'])).toBe("echo 'hello world'") + expect(buildCommand(['echo', "it's ok"])).toBe("echo 'it'\"'\"'s ok'") + }) + + test('readStdinFrom reads full input and resolves on EOF', async () => { + const stream = Readable.from(['foo', 'bar']) + await expect(readStdinFrom(stream)).resolves.toEqual(Buffer.from('foobar')) + }) + + test('readStdinFrom handles EOF without trailing newline', async () => { + const stream = Readable.from(['no-newline']) + await expect(readStdinFrom(stream)).resolves.toEqual( + Buffer.from('no-newline') + ) + }) + + test('readStdinIfPiped returns undefined when stdin is not a pipe', async () => { + const fsMock = { + fstatSync: () => ({ isFIFO: () => false }), + } + const stream = new PassThrough() + stream.end('data') + await expect(readStdinIfPiped({ fsModule: fsMock, stream })).resolves.toBe( + undefined + ) + }) + + test('readStdinIfPiped reads from provided stream when piped', async () => { + const fsMock = { + fstatSync: () => ({ isFIFO: () => true }), + } + const stream = new PassThrough() + const promise = readStdinIfPiped({ fsModule: fsMock, stream }) + stream.write(Buffer.from([0xe2, 0x98])) + stream.write(Buffer.from([0x83])) + stream.end(Buffer.from([0x21])) + await expect(promise).resolves.toEqual(Buffer.from([0xe2, 0x98, 0x83, 0x21])) + }) + + test('isPipedStdin returns true for FIFO', () => { + const fsMock = { + fstatSync: () => ({ isFIFO: () => true, isCharacterDevice: () => false }), + } + expect(isPipedStdin(0, fsMock)).toBe(true) + }) + + test('isPipedStdin returns true for file redirection', () => { + const fsMock = { + fstatSync: () => ({ isFile: () => true, isCharacterDevice: () => false }), + } + expect(isPipedStdin(0, fsMock)).toBe(true) + }) + + test('isPipedStdin returns false for interactive terminal', () => { + const fsMock = { + fstatSync: () => ({ + isCharacterDevice: () => true, + isFIFO: () => false, + isFile: () => false, + }), + } + expect(isPipedStdin(0, fsMock)).toBe(false) + }) + + test('isPipedStdin returns false for non-FIFO or errors', () => { + const fsMockFalse = { + fstatSync: () => ({ isFIFO: () => false, isCharacterDevice: () => false }), + } + expect(isPipedStdin(0, fsMockFalse)).toBe(false) + + const fsMockThrow = { + fstatSync: () => { + throw new Error('fail') + }, + } + expect(isPipedStdin(0, fsMockThrow)).toBe(false) + }) + + test('chunkBytesBySize splits large input into byte-sized chunks', () => { + const maxBytes = 64 * 1024 + const data = Buffer.from('a'.repeat(maxBytes * 2 + 1)) + const chunks = chunkBytesBySize(data, maxBytes) + + expect(chunks).toHaveLength(3) + expect(chunks[0].byteLength).toBe(maxBytes) + expect(chunks[1].byteLength).toBe(maxBytes) + expect(chunks[2].byteLength).toBe(1) + expect(Buffer.concat(chunks.map((c) => Buffer.from(c)))).toEqual(data) + }) + + test('chunkBytesBySize keeps byte content intact', () => { + const maxBytes = 64 * 1024 + const data = Buffer.from('\u{1F600}'.repeat(20000)) // 😀 (4 bytes each) + const chunks = chunkBytesBySize(data, maxBytes) + + for (const chunk of chunks) { + expect(chunk.byteLength).toBeLessThanOrEqual(maxBytes) + } + expect(Buffer.concat(chunks.map((c) => Buffer.from(c)))).toEqual(data) + }) + + test('chunkBytesBySize returns empty array for empty input', () => { + const chunks = chunkBytesBySize(Buffer.alloc(0), 64 * 1024) + expect(chunks).toHaveLength(0) + }) + + test('chunkBytesBySize returns single chunk for small input', () => { + const data = Buffer.from('hello') + const chunks = chunkBytesBySize(data, 64 * 1024) + expect(chunks).toHaveLength(1) + expect(Buffer.from(chunks[0])).toEqual(data) + }) + + test('chunkBytesBySize throws on invalid maxBytes', () => { + expect(() => chunkBytesBySize(Buffer.from('data'), 0)).toThrow() + expect(() => chunkBytesBySize(Buffer.from('data'), -1)).toThrow() + }) + + test('readStdinFrom resolves with empty buffer on immediate EOF', async () => { + const stream = Readable.from([]) + await expect(readStdinFrom(stream)).resolves.toEqual(Buffer.alloc(0)) + }) + + test('streamStdinChunks delivers chunks incrementally before EOF', async () => { + const stream = new PassThrough() + const seen: Buffer[] = [] + let resolveFirstChunk: (() => void) | undefined + const firstChunkSeen = new Promise((resolve) => { + resolveFirstChunk = resolve + }) + const done = streamStdinChunks( + stream, + async (chunk) => { + seen.push(Buffer.from(chunk)) + if (resolveFirstChunk) { + resolveFirstChunk() + resolveFirstChunk = undefined + } + }, + 64 * 1024 + ) + + stream.write('first') + await firstChunkSeen + + expect(seen).toEqual([Buffer.from('first')]) + + stream.end('second') + await done + + expect(seen).toEqual([Buffer.from('first'), Buffer.from('second')]) + }) + + test('streamStdinChunks splits oversized stream chunks by max bytes', async () => { + const stream = Readable.from([Buffer.from('a'.repeat(64 * 1024 + 3))]) + const chunks: Uint8Array[] = [] + + await streamStdinChunks( + stream, + async (chunk) => { + chunks.push(chunk) + }, + 64 * 1024 + ) + + expect(chunks).toHaveLength(2) + expect(chunks[0].byteLength).toBe(64 * 1024) + expect(chunks[1].byteLength).toBe(3) + expect(Buffer.concat(chunks.map((c) => Buffer.from(c)))).toEqual( + Buffer.from('a'.repeat(64 * 1024 + 3)) + ) + }) + + test('streamStdinChunks stops early when onChunk returns false', async () => { + const stream = Readable.from([Buffer.from('first'), Buffer.from('second')]) + const chunks: Uint8Array[] = [] + + await streamStdinChunks( + stream, + async (chunk) => { + chunks.push(chunk) + return false + }, + 64 * 1024 + ) + + expect(chunks).toHaveLength(1) + expect(Buffer.from(chunks[0])).toEqual(Buffer.from('first')) + }) +}) diff --git a/packages/cli/tests/commands/sandbox/exec_pipe.test.ts b/packages/cli/tests/commands/sandbox/exec_pipe.test.ts new file mode 100644 index 0000000..d5d5bef --- /dev/null +++ b/packages/cli/tests/commands/sandbox/exec_pipe.test.ts @@ -0,0 +1,188 @@ +import { randomBytes } from 'node:crypto' +import { describe, expect, test } from 'vitest' +import { Sandbox } from 'e2b' +import { getUserConfig } from 'src/user' +import { + type CliRunResult, + bufferToText, + isDebug, + parseEnvInt, + runCliWithPipedStdin, +} from '../../setup' + +type PipeCase = { + name: string + data: Buffer + expectedBytes: number + timeoutMs?: number +} + +type UserConfigWithDomain = NonNullable> & { + domain?: string + E2B_DOMAIN?: string +} + +const userConfig = safeGetUserConfig() as UserConfigWithDomain | null +const domain = + process.env.E2B_DOMAIN || + userConfig?.E2B_DOMAIN || + userConfig?.domain || + 'e2b.app' +const apiKey = process.env.E2B_API_KEY || userConfig?.teamApiKey +const shouldSkip = !apiKey || isDebug +const integrationTest = test.skipIf(shouldSkip) +const templateId = + process.env.E2B_PIPE_TEMPLATE_ID || + process.env.E2B_TEMPLATE_ID || + 'base' +const includeLargeBinary = + process.env.E2B_PIPE_INTEGRATION_STRICT === '1' || + process.env.E2B_PIPE_INTEGRATION_BINARY === '1' || + process.env.STRICT === '1' +const sandboxTimeoutMs = parseEnvInt('E2B_PIPE_SANDBOX_TIMEOUT_MS', 10_000) +const testTimeoutMs = parseEnvInt('E2B_PIPE_TEST_TIMEOUT_MS', 60_000) +const defaultCmdTimeoutMs = parseEnvInt( + 'E2B_PIPE_CMD_TIMEOUT_MS', + Math.min(8_000, testTimeoutMs) +) +const cliEnv: NodeJS.ProcessEnv = { + ...process.env, + E2B_DOMAIN: domain, + E2B_API_KEY: apiKey, +} + +delete cliEnv.E2B_DEBUG + +const defaultCases: PipeCase[] = [ + { + name: 'empty_eof', + data: Buffer.alloc(0), + expectedBytes: 0, + }, + { + name: 'ascii_newline', + data: Buffer.from('hello\n'), + expectedBytes: 6, + }, + { + name: 'ascii_no_newline', + data: Buffer.from('hello'), + expectedBytes: 5, + }, + { + name: 'utf8_multibyte', + data: Buffer.from([0x68, 0x69, 0x2d, 0xe2, 0x98, 0x83]), // "hi-☃" + expectedBytes: 6, + }, + { + name: 'binary_nul_ff_hex', + data: Buffer.from([0x00, 0x01, 0x02, 0xff, 0x00, 0x41]), + expectedBytes: 6, + }, + { + name: 'chunk_64k', + data: Buffer.from('a'.repeat(64 * 1024)), + expectedBytes: 64 * 1024, + }, + { + name: 'chunk_64k_plus_1', + data: Buffer.from('a'.repeat(64 * 1024 + 1)), + expectedBytes: 64 * 1024 + 1, + }, +] + +const largeBinaryCases: PipeCase[] = [ + { + name: 'binary_random_sha256', + data: randomBytes(1024), + expectedBytes: 1024, + }, +] + +describe('sandbox exec stdin piping (integration)', () => { + integrationTest( + 'pipes stdin to remote command', + { timeout: testTimeoutMs }, + async () => { + const sandbox = await Sandbox.create(templateId, { + apiKey, + domain, + timeoutMs: sandboxTimeoutMs, + }) + + try { + const cases = includeLargeBinary + ? [...defaultCases, ...largeBinaryCases] + : defaultCases + + // Probe with a simple case first — some environments (notably Windows + // CI) don't expose piped stdin so the remote byte count is 0. + const probe = cases[1] // ascii_newline + const probeResult = await runExecPipe(sandbox.sandboxId, probe) + assertExecSucceeded(probe.name, probeResult) + + const probeStdout = bufferToText(probeResult.stdout).trim() + if (probeStdout === '0') { + return + } + + expect(probeStdout).toBe(String(probe.expectedBytes)) + + for (const testCase of cases) { + const result = await runExecPipe(sandbox.sandboxId, testCase) + assertExecSucceeded(testCase.name, result) + const stdout = bufferToText(result.stdout).trim() + expect(stdout, testCase.name).toBe(String(testCase.expectedBytes)) + } + } finally { + try { + await sandbox.kill() + } catch (err) { + console.warn( + `Failed to kill sandbox ${sandbox.sandboxId}: ${String(err)}` + ) + } + } + } + ) +}) + +function runExecPipe( + sandboxId: string, + testCase: PipeCase +): Promise { + return runCliWithPipedStdin( + ['sandbox', 'exec', sandboxId, '--', 'sh', '-lc', 'wc -c'], + testCase.data, + { + env: cliEnv, + timeoutMs: testCase.timeoutMs ?? defaultCmdTimeoutMs, + } + ) +} + +function assertExecSucceeded( + name: string, + result: CliRunResult +): void { + if (result.error) { + const timedOut = (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT' + throw new Error( + `${name} ${timedOut ? 'timed out' : 'failed'}: ${result.error.message}` + ) + } + + const stderr = bufferToText(result.stderr).trim() + if (result.status !== 0) { + throw new Error(`${name} failed with rc=${result.status} stderr=${stderr}`) + } +} + +function safeGetUserConfig(): ReturnType | null { + try { + return getUserConfig() + } catch (err) { + console.warn(`Failed to read ~/.e2b/config.json: ${String(err)}`) + return null + } +} diff --git a/packages/cli/tests/commands/sandbox/exec_stdin_flag.test.ts b/packages/cli/tests/commands/sandbox/exec_stdin_flag.test.ts new file mode 100644 index 0000000..89b4b57 --- /dev/null +++ b/packages/cli/tests/commands/sandbox/exec_stdin_flag.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const connect = vi.fn() + const run = vi.fn() + const sendStdin = vi.fn() + const closeStdin = vi.fn() + const ensureAPIKey = vi.fn(() => 'test-api-key') + const isPipedStdin = vi.fn() + const streamStdinChunks = vi.fn() + const setupSignalHandlers = vi.fn(() => () => {}) + + return { + connect, + run, + sendStdin, + closeStdin, + ensureAPIKey, + isPipedStdin, + streamStdinChunks, + setupSignalHandlers, + } +}) + +vi.mock('e2b', () => { + class CommandExitError extends Error { + exitCode: number + constructor(exitCode: number) { + super(`Command exited with ${exitCode}`) + this.exitCode = exitCode + } + } + + class NotFoundError extends Error {} + + return { + Sandbox: { + connect: mocks.connect, + }, + CommandExitError, + NotFoundError, + } +}) + +vi.mock('../../../src/api', () => ({ + ensureAPIKey: mocks.ensureAPIKey, +})) + +vi.mock('src/utils/signal', () => ({ + setupSignalHandlers: mocks.setupSignalHandlers, +})) + +vi.mock( + '../../../src/commands/sandbox/exec_helpers', + async (importOriginal: () => Promise) => { + const actual = + await importOriginal< + typeof import('../../../src/commands/sandbox/exec_helpers') + >() + return { + ...actual, + isPipedStdin: mocks.isPipedStdin, + streamStdinChunks: mocks.streamStdinChunks, + } + } +) + +describe('sandbox exec stdin run flag', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + + const handle = { + pid: 1234, + error: undefined, + wait: vi.fn().mockResolvedValue({ exitCode: 0 }), + kill: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + } + + mocks.run.mockResolvedValue(handle) + mocks.sendStdin.mockResolvedValue(undefined) + mocks.closeStdin.mockResolvedValue(undefined) + mocks.isPipedStdin.mockReturnValue(false) + mocks.streamStdinChunks.mockResolvedValue(undefined) + mocks.connect.mockResolvedValue({ + commands: { + run: mocks.run, + sendStdin: mocks.sendStdin, + closeStdin: mocks.closeStdin, + supportsStdinClose: true, + }, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + test('does not pass stdin flag when stdin is not piped', async () => { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) + + const { execCommand } = await import('../../../src/commands/sandbox/exec') + await execCommand.parseAsync(['sandbox-id', 'echo', 'hello'], { + from: 'user', + }) + + expect(mocks.run).toHaveBeenCalledTimes(1) + const runOpts = mocks.run.mock.calls[0][1] + expect(runOpts).not.toHaveProperty('stdin') + expect(mocks.streamStdinChunks).not.toHaveBeenCalled() + expect(exitSpy).toHaveBeenCalledWith(0) + }) + + test('passes stdin: true when stdin piping is active', async () => { + mocks.isPipedStdin.mockReturnValue(true) + const events: string[] = [] + mocks.setupSignalHandlers.mockImplementation(() => { + events.push('setup') + return () => { + events.push('cleanup') + } + }) + mocks.streamStdinChunks.mockImplementation( + async ( + _stream: NodeJS.ReadableStream, + onChunk: (chunk: Uint8Array) => Promise, + _maxBytes: number + ) => { + events.push('stream-start') + await onChunk(Buffer.from('hello')) + events.push('stream-end') + } + ) + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) + + const { execCommand } = await import('../../../src/commands/sandbox/exec') + await execCommand.parseAsync(['sandbox-id', 'cat'], { + from: 'user', + }) + + expect(mocks.run).toHaveBeenCalledTimes(1) + const runOpts = mocks.run.mock.calls[0][1] + expect(runOpts).toHaveProperty('stdin', true) + expect(mocks.streamStdinChunks).toHaveBeenCalled() + expect(mocks.sendStdin).toHaveBeenCalled() + expect(mocks.closeStdin).toHaveBeenCalled() + expect(events).toContain('setup') + expect(events).toContain('stream-start') + expect(events.indexOf('setup')).toBeLessThan(events.indexOf('stream-start')) + expect(events).toContain('cleanup') + expect(exitSpy).toHaveBeenCalledWith(0) + }) +}) diff --git a/packages/cli/tests/commands/template/create.test.ts b/packages/cli/tests/commands/template/create.test.ts new file mode 100644 index 0000000..4c877d2 --- /dev/null +++ b/packages/cli/tests/commands/template/create.test.ts @@ -0,0 +1,71 @@ +import { spawnSync } from 'node:child_process' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' + +const apiKey = process.env.E2B_API_KEY +const domain = process.env.E2B_DOMAIN || 'e2b.app' + +const cliPath = path.join(process.cwd(), 'dist', 'index.js') +const templateName = `cli-create-api-key-test-${Date.now()}` + +describe('template create cli backend integration', () => { + let testDir: string + + beforeAll(async () => { + if (!apiKey) { + throw new Error( + 'E2B_API_KEY must be set to run template create backend tests' + ) + } + testDir = await fs.mkdtemp('e2b-create-test-') + await fs.writeFile( + path.join(testDir, 'e2b.Dockerfile'), + 'FROM ubuntu:latest\n' + ) + }) + + afterAll(async () => { + if (!testDir) return + runCli(['template', 'delete', '--yes', templateName]) + await fs.rm(testDir, { recursive: true, force: true }) + }) + + test( + 'template create succeeds with E2B_API_KEY alone (no E2B_ACCESS_TOKEN)', + { timeout: 300_000 }, + () => { + const result = runCli([ + 'template', + 'create', + templateName, + '--path', + testDir, + ]) + const output = String(result.stdout || '') + String(result.stderr || '') + + expect(result.status, output).toBe(0) + // Success marker printed by create.ts on a finished build; the failure + // path prints "❌ Template build failed." instead. + expect(output).toContain('✅ Building sandbox template') + expect(output).not.toContain('❌ Template build failed') + // Auth never fell through to the access-token error box. + expect(output).not.toMatch(/You must be logged in/) + } + ) +}) + +function runCli(args: string[]): ReturnType { + // Intentionally exclude E2B_ACCESS_TOKEN from the child env so this test + // verifies the API-key-only auth path end-to-end. + return spawnSync('node', [cliPath, ...args], { + env: { + PATH: process.env.PATH, + HOME: process.env.HOME, + E2B_DOMAIN: domain, + E2B_API_KEY: apiKey, + }, + encoding: 'utf8', + timeout: 300_000, + }) +} diff --git a/packages/cli/tests/commands/template/delete.test.ts b/packages/cli/tests/commands/template/delete.test.ts new file mode 100644 index 0000000..1dda94a --- /dev/null +++ b/packages/cli/tests/commands/template/delete.test.ts @@ -0,0 +1,68 @@ +import * as fs from 'fs/promises' +import * as path from 'path' +import { execSync } from 'child_process' +import { afterEach, beforeEach, describe, expect, test } from 'vitest' + +describe('Template Delete --config', () => { + let testDir: string + + beforeEach(async () => { + testDir = await fs.mkdtemp('e2b-delete-config-test-') + + const defaultConfig = `template_id = "default-template-id" +dockerfile = "e2b.Dockerfile"` + await fs.writeFile(path.join(testDir, 'e2b.toml'), defaultConfig) + + const customConfig = `template_id = "custom-template-id" +dockerfile = "e2b.Dockerfile"` + await fs.writeFile(path.join(testDir, 'custom.toml'), customConfig) + + await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), 'FROM alpine:3.18') + }) + + afterEach(async () => { + if (testDir) { + await fs.rm(testDir, { recursive: true, force: true }) + } + }) + + test('uses the config file passed via --config even when e2b.toml exists', async () => { + const cliPath = path.join(process.cwd(), 'dist', 'index.js') + + let output = '' + try { + output = execSync( + `node "${cliPath}" template delete --yes --path "${testDir}" --config custom.toml 2>&1`, + { encoding: 'utf-8', stdio: 'pipe', timeout: 10_000 } + ) + } catch (err: any) { + output = (err?.stdout ?? '') + (err?.stderr ?? '') + } + + expect(output).toContain('Sandbox templates to delete') + expect(output).toContain('custom-template-id') + expect(output).toContain('custom.toml') + expect(output).not.toContain('default-template-id') + }) + + test('uses the default e2b.toml when --config is not provided', async () => { + const cliPath = path.join(process.cwd(), 'dist', 'index.js') + + let output = '' + try { + output = execSync( + `node "${cliPath}" template delete --yes --path "${testDir}" 2>&1`, + { encoding: 'utf-8', stdio: 'pipe', timeout: 10_000 } + ) + } catch (err: any) { + output = (err?.stdout ?? '') + (err?.stderr ?? '') + } + + expect(output).toContain('Sandbox templates to delete') + expect(output).toContain('default-template-id') + expect(output).toContain('e2b.toml') + expect(output).not.toContain('custom-template-id') + }) +}) + + diff --git a/packages/cli/tests/commands/template/fixtures/complex-python/e2b.Dockerfile b/packages/cli/tests/commands/template/fixtures/complex-python/e2b.Dockerfile new file mode 100644 index 0000000..4c1deee --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/complex-python/e2b.Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim +RUN apt-get update && apt-get install -y gcc g++ make libpq-dev && rm -rf /var/lib/apt/lists/* +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +RUN useradd -m -u 1000 appuser +WORKDIR /app +COPY requirements.txt . +RUN pip install --upgrade pip && pip install -r requirements.txt +COPY app.py . +USER appuser +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:application"] \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/complex-python/e2b.toml b/packages/cli/tests/commands/template/fixtures/complex-python/e2b.toml new file mode 100644 index 0000000..11975f0 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/complex-python/e2b.toml @@ -0,0 +1,3 @@ +template_id = "complex-python" +dockerfile = "e2b.Dockerfile" +template_name = "complex-python-app" diff --git a/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-async/build_dev.py b/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-async/build_dev.py new file mode 100644 index 0000000..25af756 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-async/build_dev.py @@ -0,0 +1,15 @@ +import asyncio +from e2b import AsyncTemplate, default_build_logger +from template import template + + +async def main(): + await AsyncTemplate.build( + template, + "complex-python-app-dev", + on_build_logs=default_build_logger(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-async/build_prod.py b/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-async/build_prod.py new file mode 100644 index 0000000..1c5f315 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-async/build_prod.py @@ -0,0 +1,15 @@ +import asyncio +from e2b import AsyncTemplate, default_build_logger +from template import template + + +async def main(): + await AsyncTemplate.build( + template, + "complex-python-app", + on_build_logs=default_build_logger(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-async/template.py b/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-async/template.py new file mode 100644 index 0000000..d38ee70 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-async/template.py @@ -0,0 +1,20 @@ +from e2b import AsyncTemplate + +template = ( + AsyncTemplate() + .from_image("python:3.11-slim") + .set_user("root") + .set_workdir("/") + .run_cmd("apt-get update && apt-get install -y gcc g++ make libpq-dev && rm -rf /var/lib/apt/lists/*") + .set_envs({ + "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONUNBUFFERED": "1", + }) + .run_cmd("useradd -m -u 1000 appuser") + .set_workdir("/app") + .copy("requirements.txt", ".") + .run_cmd("pip install --upgrade pip && pip install -r requirements.txt") + .copy("app.py", ".") + .set_user("appuser") + .set_start_cmd("sudo gunicorn --bind 0.0.0.0:8000 app:application", "sleep 20") +) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-sync/build_dev.py b/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-sync/build_dev.py new file mode 100644 index 0000000..e0b1466 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-sync/build_dev.py @@ -0,0 +1,10 @@ +from e2b import Template, default_build_logger +from template import template + + +if __name__ == "__main__": + Template.build( + template, + "complex-python-app-dev", + on_build_logs=default_build_logger(), + ) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-sync/build_prod.py b/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-sync/build_prod.py new file mode 100644 index 0000000..b363903 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-sync/build_prod.py @@ -0,0 +1,10 @@ +from e2b import Template, default_build_logger +from template import template + + +if __name__ == "__main__": + Template.build( + template, + "complex-python-app", + on_build_logs=default_build_logger(), + ) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-sync/template.py b/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-sync/template.py new file mode 100644 index 0000000..9c73b9c --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/complex-python/expected/python-sync/template.py @@ -0,0 +1,20 @@ +from e2b import Template + +template = ( + Template() + .from_image("python:3.11-slim") + .set_user("root") + .set_workdir("/") + .run_cmd("apt-get update && apt-get install -y gcc g++ make libpq-dev && rm -rf /var/lib/apt/lists/*") + .set_envs({ + "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONUNBUFFERED": "1", + }) + .run_cmd("useradd -m -u 1000 appuser") + .set_workdir("/app") + .copy("requirements.txt", ".") + .run_cmd("pip install --upgrade pip && pip install -r requirements.txt") + .copy("app.py", ".") + .set_user("appuser") + .set_start_cmd("sudo gunicorn --bind 0.0.0.0:8000 app:application", "sleep 20") +) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/complex-python/expected/typescript/build.dev.ts b/packages/cli/tests/commands/template/fixtures/complex-python/expected/typescript/build.dev.ts new file mode 100644 index 0000000..5688118 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/complex-python/expected/typescript/build.dev.ts @@ -0,0 +1,10 @@ +import { Template, defaultBuildLogger } from 'e2b' +import { template } from './template' + +async function main() { + await Template.build(template, 'complex-python-app-dev', { + onBuildLogs: defaultBuildLogger(), + }); +} + +main().catch(console.error); \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/complex-python/expected/typescript/build.prod.ts b/packages/cli/tests/commands/template/fixtures/complex-python/expected/typescript/build.prod.ts new file mode 100644 index 0000000..8fe6d9a --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/complex-python/expected/typescript/build.prod.ts @@ -0,0 +1,10 @@ +import { Template, defaultBuildLogger } from 'e2b' +import { template } from './template' + +async function main() { + await Template.build(template, 'complex-python-app', { + onBuildLogs: defaultBuildLogger(), + }); +} + +main().catch(console.error); \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/complex-python/expected/typescript/template.ts b/packages/cli/tests/commands/template/fixtures/complex-python/expected/typescript/template.ts new file mode 100644 index 0000000..885afe5 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/complex-python/expected/typescript/template.ts @@ -0,0 +1,18 @@ +import { Template } from 'e2b' + +export const template = Template() + .fromImage('python:3.11-slim') + .setUser('root') + .setWorkdir('/') + .runCmd('apt-get update && apt-get install -y gcc g++ make libpq-dev && rm -rf /var/lib/apt/lists/*') + .setEnvs({ + 'PYTHONDONTWRITEBYTECODE': '1', + 'PYTHONUNBUFFERED': '1', + }) + .runCmd('useradd -m -u 1000 appuser') + .setWorkdir('/app') + .copy('requirements.txt', '.') + .runCmd('pip install --upgrade pip && pip install -r requirements.txt') + .copy('app.py', '.') + .setUser('appuser') + .setStartCmd('sudo gunicorn --bind 0.0.0.0:8000 app:application', 'sleep 20') diff --git a/packages/cli/tests/commands/template/fixtures/copy-variations/e2b.Dockerfile b/packages/cli/tests/commands/template/fixtures/copy-variations/e2b.Dockerfile new file mode 100644 index 0000000..0e5c0c1 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/copy-variations/e2b.Dockerfile @@ -0,0 +1,4 @@ +FROM alpine:latest +COPY package.json /app/ +COPY src/index.js ./src/ +COPY config.json /etc/app/config.json \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/copy-variations/e2b.toml b/packages/cli/tests/commands/template/fixtures/copy-variations/e2b.toml new file mode 100644 index 0000000..fcc665c --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/copy-variations/e2b.toml @@ -0,0 +1,2 @@ +template_id = "copy-test" +dockerfile = "e2b.Dockerfile" \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-async/build_dev.py b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-async/build_dev.py new file mode 100644 index 0000000..1ad567d --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-async/build_dev.py @@ -0,0 +1,15 @@ +import asyncio +from e2b import AsyncTemplate, default_build_logger +from template import template + + +async def main(): + await AsyncTemplate.build( + template, + "copy-test-dev", + on_build_logs=default_build_logger(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-async/build_prod.py b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-async/build_prod.py new file mode 100644 index 0000000..f7047a2 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-async/build_prod.py @@ -0,0 +1,15 @@ +import asyncio +from e2b import AsyncTemplate, default_build_logger +from template import template + + +async def main(): + await AsyncTemplate.build( + template, + "copy-test", + on_build_logs=default_build_logger(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-async/template.py b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-async/template.py new file mode 100644 index 0000000..76193e6 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-async/template.py @@ -0,0 +1,13 @@ +from e2b import AsyncTemplate + +template = ( + AsyncTemplate() + .from_image("alpine:latest") + .set_user("root") + .set_workdir("/") + .copy("package.json", "/app/") + .copy("src/index.js", "./src/") + .copy("config.json", "/etc/app/config.json") + .set_user("user") + .set_workdir("/home/user") +) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-sync/build_dev.py b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-sync/build_dev.py new file mode 100644 index 0000000..03a5874 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-sync/build_dev.py @@ -0,0 +1,10 @@ +from e2b import Template, default_build_logger +from template import template + + +if __name__ == "__main__": + Template.build( + template, + "copy-test-dev", + on_build_logs=default_build_logger(), + ) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-sync/build_prod.py b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-sync/build_prod.py new file mode 100644 index 0000000..b01cadf --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-sync/build_prod.py @@ -0,0 +1,10 @@ +from e2b import Template, default_build_logger +from template import template + + +if __name__ == "__main__": + Template.build( + template, + "copy-test", + on_build_logs=default_build_logger(), + ) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-sync/template.py b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-sync/template.py new file mode 100644 index 0000000..eda0d72 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-sync/template.py @@ -0,0 +1,13 @@ +from e2b import Template + +template = ( + Template() + .from_image("alpine:latest") + .set_user("root") + .set_workdir("/") + .copy("package.json", "/app/") + .copy("src/index.js", "./src/") + .copy("config.json", "/etc/app/config.json") + .set_user("user") + .set_workdir("/home/user") +) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/copy-variations/expected/typescript/build.dev.ts b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/typescript/build.dev.ts new file mode 100644 index 0000000..b50a094 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/typescript/build.dev.ts @@ -0,0 +1,10 @@ +import { Template, defaultBuildLogger } from 'e2b' +import { template } from './template' + +async function main() { + await Template.build(template, 'copy-test-dev', { + onBuildLogs: defaultBuildLogger(), + }); +} + +main().catch(console.error); \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/copy-variations/expected/typescript/build.prod.ts b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/typescript/build.prod.ts new file mode 100644 index 0000000..bef2b3e --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/typescript/build.prod.ts @@ -0,0 +1,10 @@ +import { Template, defaultBuildLogger } from 'e2b' +import { template } from './template' + +async function main() { + await Template.build(template, 'copy-test', { + onBuildLogs: defaultBuildLogger(), + }); +} + +main().catch(console.error); \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/copy-variations/expected/typescript/template.ts b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/typescript/template.ts new file mode 100644 index 0000000..835891e --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/typescript/template.ts @@ -0,0 +1,11 @@ +import { Template } from 'e2b' + +export const template = Template() + .fromImage('alpine:latest') + .setUser('root') + .setWorkdir('/') + .copy('package.json', '/app/') + .copy('src/index.js', './src/') + .copy('config.json', '/etc/app/config.json') + .setUser('user') + .setWorkdir('/home/user') \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/custom-commands/e2b.Dockerfile b/packages/cli/tests/commands/template/fixtures/custom-commands/e2b.Dockerfile new file mode 100644 index 0000000..424dfa5 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/custom-commands/e2b.Dockerfile @@ -0,0 +1,3 @@ +FROM node:18 +WORKDIR /app +COPY server.js . \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/custom-commands/e2b.toml b/packages/cli/tests/commands/template/fixtures/custom-commands/e2b.toml new file mode 100644 index 0000000..aa4ef07 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/custom-commands/e2b.toml @@ -0,0 +1,4 @@ +template_id = "custom-app" +dockerfile = "e2b.Dockerfile" +start_cmd = "node server.js" +ready_cmd = "curl -f http://localhost:3000/health || exit 1" \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-async/build_dev.py b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-async/build_dev.py new file mode 100644 index 0000000..c4084ca --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-async/build_dev.py @@ -0,0 +1,15 @@ +import asyncio +from e2b import AsyncTemplate, default_build_logger +from template import template + + +async def main(): + await AsyncTemplate.build( + template, + "custom-app-dev", + on_build_logs=default_build_logger(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-async/build_prod.py b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-async/build_prod.py new file mode 100644 index 0000000..0bcb298 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-async/build_prod.py @@ -0,0 +1,15 @@ +import asyncio +from e2b import AsyncTemplate, default_build_logger +from template import template + + +async def main(): + await AsyncTemplate.build( + template, + "custom-app", + on_build_logs=default_build_logger(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-async/template.py b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-async/template.py new file mode 100644 index 0000000..03a4e22 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-async/template.py @@ -0,0 +1,12 @@ +from e2b import AsyncTemplate + +template = ( + AsyncTemplate() + .from_image("node:18") + .set_user("root") + .set_workdir("/") + .set_workdir("/app") + .copy("server.js", ".") + .set_user("user") + .set_start_cmd("sudo node server.js", "curl -f http://localhost:3000/health || exit 1") +) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-sync/build_dev.py b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-sync/build_dev.py new file mode 100644 index 0000000..680b692 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-sync/build_dev.py @@ -0,0 +1,10 @@ +from e2b import Template, default_build_logger +from template import template + + +if __name__ == "__main__": + Template.build( + template, + "custom-app-dev", + on_build_logs=default_build_logger(), + ) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-sync/build_prod.py b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-sync/build_prod.py new file mode 100644 index 0000000..e6c1ca7 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-sync/build_prod.py @@ -0,0 +1,10 @@ +from e2b import Template, default_build_logger +from template import template + + +if __name__ == "__main__": + Template.build( + template, + "custom-app", + on_build_logs=default_build_logger(), + ) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-sync/template.py b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-sync/template.py new file mode 100644 index 0000000..3d155f6 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/python-sync/template.py @@ -0,0 +1,12 @@ +from e2b import Template + +template = ( + Template() + .from_image("node:18") + .set_user("root") + .set_workdir("/") + .set_workdir("/app") + .copy("server.js", ".") + .set_user("user") + .set_start_cmd("sudo node server.js", "curl -f http://localhost:3000/health || exit 1") +) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/custom-commands/expected/typescript/build.dev.ts b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/typescript/build.dev.ts new file mode 100644 index 0000000..fcd8a60 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/typescript/build.dev.ts @@ -0,0 +1,10 @@ +import { Template, defaultBuildLogger } from 'e2b' +import { template } from './template' + +async function main() { + await Template.build(template, 'custom-app-dev', { + onBuildLogs: defaultBuildLogger(), + }); +} + +main().catch(console.error); \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/custom-commands/expected/typescript/build.prod.ts b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/typescript/build.prod.ts new file mode 100644 index 0000000..00ac223 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/typescript/build.prod.ts @@ -0,0 +1,10 @@ +import { Template, defaultBuildLogger } from 'e2b' +import { template } from './template' + +async function main() { + await Template.build(template, 'custom-app', { + onBuildLogs: defaultBuildLogger(), + }); +} + +main().catch(console.error); \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/custom-commands/expected/typescript/template.ts b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/typescript/template.ts new file mode 100644 index 0000000..dcf84f4 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/custom-commands/expected/typescript/template.ts @@ -0,0 +1,10 @@ +import { Template } from 'e2b' + +export const template = Template() + .fromImage('node:18') + .setUser('root') + .setWorkdir('/') + .setWorkdir('/app') + .copy('server.js', '.') + .setUser('user') + .setStartCmd('sudo node server.js', 'curl -f http://localhost:3000/health || exit 1') \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/e2b.Dockerfile b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/e2b.Dockerfile new file mode 100644 index 0000000..08ee655 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/e2b.Dockerfile @@ -0,0 +1 @@ +FROM ubuntu:latest \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/e2b.toml b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/e2b.toml new file mode 100644 index 0000000..c20d251 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/e2b.toml @@ -0,0 +1,2 @@ +template_id = "minimal-template" +dockerfile = "e2b.Dockerfile" \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-async/build_dev.py b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-async/build_dev.py new file mode 100644 index 0000000..f9178ee --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-async/build_dev.py @@ -0,0 +1,15 @@ +import asyncio +from e2b import AsyncTemplate, default_build_logger +from template import template + + +async def main(): + await AsyncTemplate.build( + template, + "minimal-template-dev", + on_build_logs=default_build_logger(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-async/build_prod.py b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-async/build_prod.py new file mode 100644 index 0000000..08f0c74 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-async/build_prod.py @@ -0,0 +1,15 @@ +import asyncio +from e2b import AsyncTemplate, default_build_logger +from template import template + + +async def main(): + await AsyncTemplate.build( + template, + "minimal-template", + on_build_logs=default_build_logger(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-async/template.py b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-async/template.py new file mode 100644 index 0000000..416a846 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-async/template.py @@ -0,0 +1,10 @@ +from e2b import AsyncTemplate + +template = ( + AsyncTemplate() + .from_image("ubuntu:latest") + .set_user("root") + .set_workdir("/") + .set_user("user") + .set_workdir("/home/user") +) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-sync/build_dev.py b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-sync/build_dev.py new file mode 100644 index 0000000..4df82d1 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-sync/build_dev.py @@ -0,0 +1,10 @@ +from e2b import Template, default_build_logger +from template import template + + +if __name__ == "__main__": + Template.build( + template, + "minimal-template-dev", + on_build_logs=default_build_logger(), + ) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-sync/build_prod.py b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-sync/build_prod.py new file mode 100644 index 0000000..30acefb --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-sync/build_prod.py @@ -0,0 +1,10 @@ +from e2b import Template, default_build_logger +from template import template + + +if __name__ == "__main__": + Template.build( + template, + "minimal-template", + on_build_logs=default_build_logger(), + ) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-sync/template.py b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-sync/template.py new file mode 100644 index 0000000..feaa6a2 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/python-sync/template.py @@ -0,0 +1,10 @@ +from e2b import Template + +template = ( + Template() + .from_image("ubuntu:latest") + .set_user("root") + .set_workdir("/") + .set_user("user") + .set_workdir("/home/user") +) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/typescript/build.dev.ts b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/typescript/build.dev.ts new file mode 100644 index 0000000..8cd195a --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/typescript/build.dev.ts @@ -0,0 +1,10 @@ +import { Template, defaultBuildLogger } from 'e2b' +import { template } from './template' + +async function main() { + await Template.build(template, 'minimal-template-dev', { + onBuildLogs: defaultBuildLogger(), + }); +} + +main().catch(console.error); \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/typescript/build.prod.ts b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/typescript/build.prod.ts new file mode 100644 index 0000000..fdc463a --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/typescript/build.prod.ts @@ -0,0 +1,10 @@ +import { Template, defaultBuildLogger } from 'e2b' +import { template } from './template' + +async function main() { + await Template.build(template, 'minimal-template', { + onBuildLogs: defaultBuildLogger(), + }); +} + +main().catch(console.error); \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/typescript/template.ts b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/typescript/template.ts new file mode 100644 index 0000000..514bcdd --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/minimal-dockerfile/expected/typescript/template.ts @@ -0,0 +1,8 @@ +import { Template } from 'e2b' + +export const template = Template() + .fromImage('ubuntu:latest') + .setUser('root') + .setWorkdir('/') + .setUser('user') + .setWorkdir('/home/user') \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multi-stage/e2b.Dockerfile b/packages/cli/tests/commands/template/fixtures/multi-stage/e2b.Dockerfile new file mode 100644 index 0000000..be54a6d --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multi-stage/e2b.Dockerfile @@ -0,0 +1,9 @@ +FROM node:18 AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci + +FROM node:18-slim +WORKDIR /app +COPY --from=builder /app/node_modules ./node_modules +CMD ["node", "index.js"] \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multi-stage/e2b.toml b/packages/cli/tests/commands/template/fixtures/multi-stage/e2b.toml new file mode 100644 index 0000000..a0e97d1 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multi-stage/e2b.toml @@ -0,0 +1,2 @@ +template_id = "multi-stage" +dockerfile = "e2b.Dockerfile" \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-async/build_dev.py b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-async/build_dev.py new file mode 100644 index 0000000..16456db --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-async/build_dev.py @@ -0,0 +1,15 @@ +import asyncio +from e2b import AsyncTemplate, default_build_logger +from template import template + + +async def main(): + await AsyncTemplate.build( + template, + "multi-stage-dev", + on_build_logs=default_build_logger(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-async/build_prod.py b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-async/build_prod.py new file mode 100644 index 0000000..3cecb8d --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-async/build_prod.py @@ -0,0 +1,15 @@ +import asyncio +from e2b import AsyncTemplate, default_build_logger +from template import template + + +async def main(): + await AsyncTemplate.build( + template, + "multi-stage", + on_build_logs=default_build_logger(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-async/template.py b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-async/template.py new file mode 100644 index 0000000..9edefaa --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-async/template.py @@ -0,0 +1,6 @@ +from e2b import AsyncTemplate + +template = ( + AsyncTemplate() + .from_image("my-custom-image") +) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-sync/build_dev.py b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-sync/build_dev.py new file mode 100644 index 0000000..cc7e4e9 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-sync/build_dev.py @@ -0,0 +1,10 @@ +from e2b import Template, default_build_logger +from template import template + + +if __name__ == "__main__": + Template.build( + template, + "multi-stage-dev", + on_build_logs=default_build_logger(), + ) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-sync/build_prod.py b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-sync/build_prod.py new file mode 100644 index 0000000..c8aff7e --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-sync/build_prod.py @@ -0,0 +1,10 @@ +from e2b import Template, default_build_logger +from template import template + + +if __name__ == "__main__": + Template.build( + template, + "multi-stage", + on_build_logs=default_build_logger(), + ) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-sync/template.py b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-sync/template.py new file mode 100644 index 0000000..ce79dd0 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/python-sync/template.py @@ -0,0 +1,6 @@ +from e2b import Template + +template = ( + Template() + .from_image("my-custom-image") +) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multi-stage/expected/typescript/build.dev.ts b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/typescript/build.dev.ts new file mode 100644 index 0000000..e11abe5 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/typescript/build.dev.ts @@ -0,0 +1,10 @@ +import { Template, defaultBuildLogger } from 'e2b' +import { template } from './template' + +async function main() { + await Template.build(template, 'multi-stage-dev', { + onBuildLogs: defaultBuildLogger(), + }); +} + +main().catch(console.error); \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multi-stage/expected/typescript/build.prod.ts b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/typescript/build.prod.ts new file mode 100644 index 0000000..9f7b84c --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/typescript/build.prod.ts @@ -0,0 +1,10 @@ +import { Template, defaultBuildLogger } from 'e2b' +import { template } from './template' + +async function main() { + await Template.build(template, 'multi-stage', { + onBuildLogs: defaultBuildLogger(), + }); +} + +main().catch(console.error); \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multi-stage/expected/typescript/template.ts b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/typescript/template.ts new file mode 100644 index 0000000..c69e38b --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multi-stage/expected/typescript/template.ts @@ -0,0 +1,4 @@ +import { Template } from 'e2b' + +export const template = Template() + .fromImage('my-custom-image') \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multiple-env/e2b.Dockerfile b/packages/cli/tests/commands/template/fixtures/multiple-env/e2b.Dockerfile new file mode 100644 index 0000000..8a91a1a --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multiple-env/e2b.Dockerfile @@ -0,0 +1,6 @@ +FROM node:18 +ENV NODE_ENV=production +ENV PORT 3000 +ENV DEBUG=false LOG_LEVEL=info API_URL=https://api.example.com +ENV SINGLE_VAR single_value +WORKDIR /app \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multiple-env/e2b.toml b/packages/cli/tests/commands/template/fixtures/multiple-env/e2b.toml new file mode 100644 index 0000000..d38bb83 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multiple-env/e2b.toml @@ -0,0 +1,2 @@ +template_id = "env-test" +dockerfile = "e2b.Dockerfile" \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-async/build_dev.py b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-async/build_dev.py new file mode 100644 index 0000000..03d821c --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-async/build_dev.py @@ -0,0 +1,15 @@ +import asyncio +from e2b import AsyncTemplate, default_build_logger +from template import template + + +async def main(): + await AsyncTemplate.build( + template, + "env-test-dev", + on_build_logs=default_build_logger(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-async/build_prod.py b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-async/build_prod.py new file mode 100644 index 0000000..00fbf6a --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-async/build_prod.py @@ -0,0 +1,15 @@ +import asyncio +from e2b import AsyncTemplate, default_build_logger +from template import template + + +async def main(): + await AsyncTemplate.build( + template, + "env-test", + on_build_logs=default_build_logger(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-async/template.py b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-async/template.py new file mode 100644 index 0000000..345b525 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-async/template.py @@ -0,0 +1,24 @@ +from e2b import AsyncTemplate + +template = ( + AsyncTemplate() + .from_image("node:18") + .set_user("root") + .set_workdir("/") + .set_envs({ + "NODE_ENV": "production", + }) + .set_envs({ + "PORT": "3000", + }) + .set_envs({ + "DEBUG": "false", + "LOG_LEVEL": "info", + "API_URL": "https://api.example.com", + }) + .set_envs({ + "SINGLE_VAR": "single_value", + }) + .set_workdir("/app") + .set_user("user") +) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-sync/build_dev.py b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-sync/build_dev.py new file mode 100644 index 0000000..1cac587 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-sync/build_dev.py @@ -0,0 +1,10 @@ +from e2b import Template, default_build_logger +from template import template + + +if __name__ == "__main__": + Template.build( + template, + "env-test-dev", + on_build_logs=default_build_logger(), + ) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-sync/build_prod.py b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-sync/build_prod.py new file mode 100644 index 0000000..e31216e --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-sync/build_prod.py @@ -0,0 +1,10 @@ +from e2b import Template, default_build_logger +from template import template + + +if __name__ == "__main__": + Template.build( + template, + "env-test", + on_build_logs=default_build_logger(), + ) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-sync/template.py b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-sync/template.py new file mode 100644 index 0000000..01eecfa --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/python-sync/template.py @@ -0,0 +1,24 @@ +from e2b import Template + +template = ( + Template() + .from_image("node:18") + .set_user("root") + .set_workdir("/") + .set_envs({ + "NODE_ENV": "production", + }) + .set_envs({ + "PORT": "3000", + }) + .set_envs({ + "DEBUG": "false", + "LOG_LEVEL": "info", + "API_URL": "https://api.example.com", + }) + .set_envs({ + "SINGLE_VAR": "single_value", + }) + .set_workdir("/app") + .set_user("user") +) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multiple-env/expected/typescript/build.dev.ts b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/typescript/build.dev.ts new file mode 100644 index 0000000..d1256a3 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/typescript/build.dev.ts @@ -0,0 +1,10 @@ +import { Template, defaultBuildLogger } from 'e2b' +import { template } from './template' + +async function main() { + await Template.build(template, 'env-test-dev', { + onBuildLogs: defaultBuildLogger(), + }); +} + +main().catch(console.error); \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multiple-env/expected/typescript/build.prod.ts b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/typescript/build.prod.ts new file mode 100644 index 0000000..8f1dedc --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/typescript/build.prod.ts @@ -0,0 +1,10 @@ +import { Template, defaultBuildLogger } from 'e2b' +import { template } from './template' + +async function main() { + await Template.build(template, 'env-test', { + onBuildLogs: defaultBuildLogger(), + }); +} + +main().catch(console.error); \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/multiple-env/expected/typescript/template.ts b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/typescript/template.ts new file mode 100644 index 0000000..8c30525 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/multiple-env/expected/typescript/template.ts @@ -0,0 +1,22 @@ +import { Template } from 'e2b' + +export const template = Template() + .fromImage('node:18') + .setUser('root') + .setWorkdir('/') + .setEnvs({ + 'NODE_ENV': 'production', + }) + .setEnvs({ + 'PORT': '3000', + }) + .setEnvs({ + 'DEBUG': 'false', + 'LOG_LEVEL': 'info', + 'API_URL': 'https://api.example.com', + }) + .setEnvs({ + 'SINGLE_VAR': 'single_value', + }) + .setWorkdir('/app') + .setUser('user') \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/start-cmd/e2b.Dockerfile b/packages/cli/tests/commands/template/fixtures/start-cmd/e2b.Dockerfile new file mode 100644 index 0000000..4764511 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/start-cmd/e2b.Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11 +WORKDIR /app +RUN pip install --upgrade pip +RUN pip install -r requirements.txt +ENV PYTHONUNBUFFERED=1 +CMD ["python", "app.py"] \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/start-cmd/e2b.toml b/packages/cli/tests/commands/template/fixtures/start-cmd/e2b.toml new file mode 100644 index 0000000..283ea1a --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/start-cmd/e2b.toml @@ -0,0 +1,5 @@ +template_id = "start-cmd" +dockerfile = "e2b.Dockerfile" +cpu_count = 2 +memory_mb = 1024 +start_cmd = "node server.js" \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-async/build_dev.py b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-async/build_dev.py new file mode 100644 index 0000000..4f1bb29 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-async/build_dev.py @@ -0,0 +1,17 @@ +import asyncio +from e2b import AsyncTemplate, default_build_logger +from template import template + + +async def main(): + await AsyncTemplate.build( + template, + "start-cmd-dev", + cpu_count=2, + memory_mb=1024, + on_build_logs=default_build_logger(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-async/build_prod.py b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-async/build_prod.py new file mode 100644 index 0000000..6eec995 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-async/build_prod.py @@ -0,0 +1,17 @@ +import asyncio +from e2b import AsyncTemplate, default_build_logger +from template import template + + +async def main(): + await AsyncTemplate.build( + template, + "start-cmd", + cpu_count=2, + memory_mb=1024, + on_build_logs=default_build_logger(), + ) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-async/template.py b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-async/template.py new file mode 100644 index 0000000..4b14d3d --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-async/template.py @@ -0,0 +1,16 @@ +from e2b import AsyncTemplate + +template = ( + AsyncTemplate() + .from_image("python:3.11") + .set_user("root") + .set_workdir("/") + .set_workdir("/app") + .run_cmd("pip install --upgrade pip") + .run_cmd("pip install -r requirements.txt") + .set_envs({ + "PYTHONUNBUFFERED": "1", + }) + .set_user("user") + .set_start_cmd("sudo node server.js", "sleep 20") +) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-sync/build_dev.py b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-sync/build_dev.py new file mode 100644 index 0000000..f0ae5cd --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-sync/build_dev.py @@ -0,0 +1,12 @@ +from e2b import Template, default_build_logger +from template import template + + +if __name__ == "__main__": + Template.build( + template, + "start-cmd-dev", + cpu_count=2, + memory_mb=1024, + on_build_logs=default_build_logger(), + ) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-sync/build_prod.py b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-sync/build_prod.py new file mode 100644 index 0000000..c3f33de --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-sync/build_prod.py @@ -0,0 +1,12 @@ +from e2b import Template, default_build_logger +from template import template + + +if __name__ == "__main__": + Template.build( + template, + "start-cmd", + cpu_count=2, + memory_mb=1024, + on_build_logs=default_build_logger(), + ) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-sync/template.py b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-sync/template.py new file mode 100644 index 0000000..aa2d9ac --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/python-sync/template.py @@ -0,0 +1,16 @@ +from e2b import Template + +template = ( + Template() + .from_image("python:3.11") + .set_user("root") + .set_workdir("/") + .set_workdir("/app") + .run_cmd("pip install --upgrade pip") + .run_cmd("pip install -r requirements.txt") + .set_envs({ + "PYTHONUNBUFFERED": "1", + }) + .set_user("user") + .set_start_cmd("sudo node server.js", "sleep 20") +) \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/start-cmd/expected/typescript/build.dev.ts b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/typescript/build.dev.ts new file mode 100644 index 0000000..1fd0553 --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/typescript/build.dev.ts @@ -0,0 +1,12 @@ +import { Template, defaultBuildLogger } from 'e2b' +import { template } from './template' + +async function main() { + await Template.build(template, 'start-cmd-dev', { + cpuCount: 2, + memoryMB: 1024, + onBuildLogs: defaultBuildLogger(), + }); +} + +main().catch(console.error); \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/start-cmd/expected/typescript/build.prod.ts b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/typescript/build.prod.ts new file mode 100644 index 0000000..1e39efa --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/typescript/build.prod.ts @@ -0,0 +1,12 @@ +import { Template, defaultBuildLogger } from 'e2b' +import { template } from './template' + +async function main() { + await Template.build(template, 'start-cmd', { + cpuCount: 2, + memoryMB: 1024, + onBuildLogs: defaultBuildLogger(), + }); +} + +main().catch(console.error); \ No newline at end of file diff --git a/packages/cli/tests/commands/template/fixtures/start-cmd/expected/typescript/template.ts b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/typescript/template.ts new file mode 100644 index 0000000..386041e --- /dev/null +++ b/packages/cli/tests/commands/template/fixtures/start-cmd/expected/typescript/template.ts @@ -0,0 +1,14 @@ +import { Template } from 'e2b' + +export const template = Template() + .fromImage('python:3.11') + .setUser('root') + .setWorkdir('/') + .setWorkdir('/app') + .runCmd('pip install --upgrade pip') + .runCmd('pip install -r requirements.txt') + .setEnvs({ + 'PYTHONUNBUFFERED': '1', + }) + .setUser('user') + .setStartCmd('sudo node server.js', 'sleep 20') \ No newline at end of file diff --git a/packages/cli/tests/commands/template/init.test.ts b/packages/cli/tests/commands/template/init.test.ts new file mode 100644 index 0000000..a46d9ce --- /dev/null +++ b/packages/cli/tests/commands/template/init.test.ts @@ -0,0 +1,366 @@ +import { execSync } from 'child_process' +import { existsSync } from 'fs' +import * as fs from 'fs/promises' +import * as path from 'path' +import { afterEach, beforeEach, describe, expect, test } from 'vitest' +import { Language } from '../../../src/commands/template/generators' + +describe('Template Init', () => { + let testDir: string + const cliPath = path.join(process.cwd(), 'dist', 'index.js') + + beforeEach(async () => { + // Use Node.js built-in temp directory handling + testDir = await fs.mkdtemp('e2b-init-test-') + }) + + afterEach(async () => { + // Clean up test directory + if (testDir) { + await fs.rm(testDir, { recursive: true, force: true }) + } + }) + + describe('CLI Options', () => { + Object.values(Language).forEach((language) => { + test(`should generate files with --name and --language ${language}`, async () => { + const templateName = 'my-test-template' + + // Run init command with CLI options + execSync( + `node "${cliPath}" template init --name "${templateName}" --language "${language}" --path "${testDir}"`, + { stdio: 'inherit' } + ) + + // Verify template directory was created + const templateDir = path.join(testDir, templateName) + expect(existsSync(templateDir)).toBe(true) + + // Verify files were created in the template directory + const expectedFiles = getExpectedFiles(language) + + for (const file of expectedFiles) { + const filePath = path.join(templateDir, file) + expect(existsSync(filePath)).toBe(true) + + // Verify file content is not empty + const content = await fs.readFile(filePath, 'utf8') + expect(content.trim().length).toBeGreaterThan(0) + } + + // Verify template name is used in build files + await verifyTemplateNameInBuildFiles( + templateDir, + language, + templateName + ) + }) + }) + + test('should validate template name format', async () => { + // Matches the server-side rule in e2b-dev/infra (id.identifierRegex): + // only lowercase letters, numbers, dashes and underscores are allowed. + const invalidNames = [ + 'invalid space', // contains space + 'invalid.dot', // contains a dot + '', // empty + 'a'.repeat(129), // exceeds the 128 character limit + ] + + for (const invalidName of invalidNames) { + expect(() => { + execSync( + `node "${cliPath}" template init --name "${invalidName}" --language "typescript" --path "${testDir}"`, + { stdio: 'pipe' } + ) + }).toThrow() + } + }) + + test('should validate language parameter', async () => { + expect(() => { + execSync( + `node "${cliPath}" template init --name "test" --language "invalid-lang" --path "${testDir}"`, + { stdio: 'pipe' } + ) + }).toThrow() + }) + + test('should work with valid template names', async () => { + const validNames = [ + 'a', // single character + 'abc', // simple + 'my-template', // with hyphens + 'my_template', // with underscores + 'my-template_name', // with hyphens and underscores + 'test123', // with numbers + '123test', // starting with number + 'a-b-c-d-e', // multiple hyphens + 'a_b_c_d_e', // multiple underscores + 'api-server_v2', // mixed hyphens and underscores + ] + + for (const validName of validNames) { + // Clean directory before each test + const files = await fs.readdir(testDir) + for (const file of files) { + await fs.rm(path.join(testDir, file), { + recursive: true, + force: true, + }) + } + + // Should not throw + execSync( + `node "${cliPath}" template init --name "${validName}" --language "typescript" --path "${testDir}"`, + { stdio: 'pipe' } + ) + + // Verify template directory was created + const templateDir = path.join(testDir, validName) + expect(existsSync(templateDir)).toBe(true) + + // Verify files were created in the template directory + const expectedFiles = getExpectedFiles(Language.TypeScript) + for (const file of expectedFiles) { + expect(existsSync(path.join(templateDir, file))).toBe(true) + } + } + }) + }) + + describe('Package.json Integration', () => { + test('should add scripts to existing package.json', async () => { + // Create a package.json file in the parent directory + const packageJson = { + name: 'test-project', + version: '1.0.0', + scripts: { + test: 'echo "test"', + }, + } + const packageJsonPath = path.join(testDir, 'package.json') + await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)) + + // Run init command + execSync( + `node "${cliPath}" template init --name "test-template" --language "typescript" --path "${testDir}"`, + { stdio: 'inherit' } + ) + + // Verify package.json was updated (it should remain in the parent directory) + const updatedPackageJson = JSON.parse( + await fs.readFile(packageJsonPath, 'utf8') + ) + + expect(updatedPackageJson.scripts).toHaveProperty('e2b:build:dev') + expect(updatedPackageJson.scripts).toHaveProperty('e2b:build:prod') + expect(updatedPackageJson.scripts.test).toBe('echo "test"') // existing script preserved + }) + + test('should work without package.json', async () => { + // Run init command without package.json + execSync( + `node "${cliPath}" template init --name "test-template" --language "typescript" --path "${testDir}"`, + { stdio: 'inherit' } + ) + + // Verify template directory was created + const templateDir = path.join(testDir, 'test-template') + expect(existsSync(templateDir)).toBe(true) + + // Verify files were created in the template directory + const expectedFiles = getExpectedFiles(Language.TypeScript) + for (const file of expectedFiles) { + expect(existsSync(path.join(templateDir, file))).toBe(true) + } + + // Verify package.json was created in the template directory + const createdPackageJSON = JSON.parse( + await fs.readFile(path.join(templateDir, 'package.json'), 'utf8') + ) + + expect(createdPackageJSON.scripts).toHaveProperty('e2b:build:dev') + expect(createdPackageJSON.scripts).toHaveProperty('e2b:build:prod') + }) + }) + + describe('Makefile Integration', () => { + test('should add scripts to existing Makefile', async () => { + // Create a Makefile file in the parent directory + const makefile = ` +.PHONY: build +build/%: +\tCGO_ENABLED=1 go build + ` + const makefilePath = path.join(testDir, 'Makefile') + await fs.writeFile(makefilePath, makefile) + + // Run init command + execSync( + `node "${cliPath}" template init --name "test-template" --language "python-sync" --path "${testDir}"`, + { stdio: 'inherit' } + ) + + // Verify Makefile was updated (it should remain in the parent directory) + const updatedMakefile = await fs.readFile(makefilePath, 'utf8') + + expect(updatedMakefile).toContain('e2b:build:dev') + expect(updatedMakefile).toContain('e2b:build:prod') + expect(updatedMakefile).toContain(`.PHONY: build +build/%: +\tCGO_ENABLED=1 go build`) // existing script preserved + }) + + test('should work without Makefile', async () => { + // Run init command without Makefile + execSync( + `node "${cliPath}" template init --name "test-template" --language "python-sync" --path "${testDir}"`, + { stdio: 'inherit' } + ) + + // Verify template directory was created + const templateDir = path.join(testDir, 'test-template') + expect(existsSync(templateDir)).toBe(true) + + // Verify files were created in the template directory + const expectedFiles = getExpectedFiles(Language.PythonSync) + for (const file of expectedFiles) { + expect(existsSync(path.join(templateDir, file))).toBe(true) + } + + // Verify Makefile was created in the template directory + const createdMakefile = await fs.readFile( + path.join(templateDir, 'Makefile'), + 'utf8' + ) + expect(createdMakefile).toContain('e2b:build:dev') + expect(createdMakefile).toContain('e2b:build:prod') + }) + }) + + describe('File Content Validation', () => { + test('should generate correct TypeScript template content', async () => { + execSync( + `node "${cliPath}" template init --name "test-ts" --language "typescript" --path "${testDir}"`, + { stdio: 'inherit' } + ) + + const templateDir = path.join(testDir, 'test-ts') + const templateContent = await fs.readFile( + path.join(templateDir, 'template.ts'), + 'utf8' + ) + + // Verify basic structure + expect(templateContent).toContain("import { Template } from 'e2b'") + expect(templateContent).toContain('export const template = Template()') + expect(templateContent).toContain('fromImage') + expect(templateContent).toContain('e2bdev/base') + }) + + test('should generate correct Python template content', async () => { + execSync( + `node "${cliPath}" template init --name "test-py" --language "python-sync" --path "${testDir}"`, + { stdio: 'inherit' } + ) + + const templateDir = path.join(testDir, 'test-py') + const templateContent = await fs.readFile( + path.join(templateDir, 'template.py'), + 'utf8' + ) + + // Verify basic structure + expect(templateContent).toContain('from e2b import Template') + expect(templateContent).toContain('template = (') + expect(templateContent).toContain('Template()') + expect(templateContent).toContain('from_image') + expect(templateContent).toContain('e2bdev/base') + }) + + test('should generate correct async Python template content', async () => { + execSync( + `node "${cliPath}" template init --name "test-py-async" --language "python-async" --path "${testDir}"`, + { stdio: 'inherit' } + ) + + const templateContent = await fs.readFile( + path.join(testDir, 'test-py-async', 'template.py'), + 'utf8' + ) + + // Verify async structure + expect(templateContent).toContain('from e2b import AsyncTemplate') + expect(templateContent).toContain('AsyncTemplate()') + }) + }) + + describe('Directory Conflict Handling', () => { + test('should fail when directory already exists', async () => { + // Create a directory that would conflict + const conflictDir = path.join(testDir, 'test') + await fs.mkdir(conflictDir) + await fs.writeFile( + path.join(conflictDir, 'existing.txt'), + 'existing content' + ) + + // Should fail when trying to create template with existing directory name + expect(() => { + execSync( + `node "${cliPath}" template init --name "test" --language "typescript" --path "${testDir}"`, + { stdio: 'pipe' } + ) + }).toThrow() + + // Verify original directory is preserved and unchanged + expect(existsSync(path.join(conflictDir, 'existing.txt'))).toBe(true) + + // Verify no template files were created in the existing directory + expect(existsSync(path.join(conflictDir, 'template.ts'))).toBe(false) + }) + }) +}) + +// Helper functions +function getExpectedFiles(language: Language): string[] { + const extension = language === Language.TypeScript ? '.ts' : '.py' + const buildDevName = + language === Language.TypeScript ? 'build.dev' : 'build_dev' + const buildProdName = + language === Language.TypeScript ? 'build.prod' : 'build_prod' + + return [ + `template${extension}`, + `${buildDevName}${extension}`, + `${buildProdName}${extension}`, + ] +} + +async function verifyTemplateNameInBuildFiles( + testDir: string, + language: Language, + templateName: string +): Promise { + const extension = language === Language.TypeScript ? '.ts' : '.py' + const buildDevName = + language === Language.TypeScript ? 'build.dev' : 'build_dev' + const buildProdName = + language === Language.TypeScript ? 'build.prod' : 'build_prod' + + // Check dev build file contains template name with -dev suffix + const devContent = await fs.readFile( + path.join(testDir, `${buildDevName}${extension}`), + 'utf8' + ) + expect(devContent).toContain(`${templateName}-dev`) + + // Check prod build file contains template name + const prodContent = await fs.readFile( + path.join(testDir, `${buildProdName}${extension}`), + 'utf8' + ) + expect(prodContent).toContain(templateName) + expect(prodContent).not.toContain(`${templateName}-dev`) // should not have -dev suffix +} diff --git a/packages/cli/tests/commands/template/migrate.test.ts b/packages/cli/tests/commands/template/migrate.test.ts new file mode 100644 index 0000000..ee6cbb3 --- /dev/null +++ b/packages/cli/tests/commands/template/migrate.test.ts @@ -0,0 +1,351 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest' +import * as fs from 'fs/promises' +import * as path from 'path' +import { execSync } from 'child_process' +import { Language } from '../../../src/commands/template/generators' + +interface FileNames { + template: string + buildDev: string + buildProd: string +} + +function getFileNames(language: Language): FileNames { + switch (language) { + case Language.TypeScript: { + return { + template: 'template.ts', + buildDev: 'build.dev.ts', + buildProd: 'build.prod.ts', + } + } + case Language.PythonSync: + case Language.PythonAsync: { + return { + template: 'template.py', + buildDev: 'build_dev.py', + buildProd: 'build_prod.py', + } + } + default: + throw new Error(`Unsupported language: ${language}`) + } +} + +describe('Template Migration', () => { + let testDir: string + const cliPath = path.join(process.cwd(), 'dist', 'index.js') + const fixturesDir = path.join(__dirname, 'fixtures') + + beforeEach(async () => { + // Use Node.js built-in temp directory handling + testDir = await fs.mkdtemp('e2b-migrate-test-') + }) + + afterEach(async () => { + // Clean up test directory + if (testDir) { + await fs.rm(testDir, { recursive: true, force: true }) + } + }) + + // Run tests for each test case + describe('Migration Test Cases', () => { + // Test case names correspond to fixture directory names + const testCases = [ + 'complex-python', + 'copy-variations', + 'custom-commands', + 'minimal-dockerfile', + 'multiple-env', + 'start-cmd', + 'multi-stage', + ] + + testCases.forEach((testCaseName) => { + describe(testCaseName.replace('-', ' '), () => { + test('should migrate to TypeScript', async () => { + await runMigrationTest(testCaseName, Language.TypeScript) + }) + + test('should migrate to Python sync', async () => { + await runMigrationTest(testCaseName, Language.PythonSync) + }) + + test('should migrate to Python async', async () => { + await runMigrationTest(testCaseName, Language.PythonAsync) + }) + }) + }) + + async function runMigrationTest(testCaseName: string, language: Language) { + const fixtureDir = path.join(fixturesDir, testCaseName) + + // Copy fixture files to test directory + await copyFixtureFiles(fixtureDir, testDir) + + // Run migration + execSync(`node ${cliPath} template migrate --language ${language}`, { + cwd: testDir, + }) + + // Determine file extensions and names based on language + const fileNames = getFileNames(language) + + // Load expected outputs + const expectedDir = path.join(fixtureDir, 'expected', language) + const expectedTemplate = await fs.readFile( + path.join(expectedDir, fileNames.template), + 'utf-8' + ) + const expectedBuildDev = await fs.readFile( + path.join(expectedDir, fileNames.buildDev), + 'utf-8' + ) + const expectedBuildProd = await fs.readFile( + path.join(expectedDir, fileNames.buildProd), + 'utf-8' + ) + + // Check generated files + const templateFile = await fs.readFile( + path.join(testDir, fileNames.template), + 'utf-8' + ) + expect(templateFile.trim()).toEqual(expectedTemplate.trim()) + + const buildDevFile = await fs.readFile( + path.join(testDir, fileNames.buildDev), + 'utf-8' + ) + expect(buildDevFile.trim()).toEqual(expectedBuildDev.trim()) + + const buildProdFile = await fs.readFile( + path.join(testDir, fileNames.buildProd), + 'utf-8' + ) + expect(buildProdFile.trim()).toEqual(expectedBuildProd.trim()) + if ( + language === Language.PythonSync || + language === Language.PythonAsync + ) { + for (const content of [buildDevFile, buildProdFile]) { + expect(content).toContain('from template import template') + expect(content).not.toContain('from .template import template') + } + } + + // Check that old files are renamed to .old extensions + const oldDockerfilePath = path.join(testDir, 'e2b.Dockerfile.old') + const oldConfigPath = path.join(testDir, 'e2b.toml.old') + + expect( + await fs + .access(oldDockerfilePath) + .then(() => true) + .catch(() => false) + ).toBe(true) + expect( + await fs + .access(oldConfigPath) + .then(() => true) + .catch(() => false) + ).toBe(true) + + // Verify original files no longer exist + const originalDockerfilePath = path.join(testDir, 'e2b.Dockerfile') + const originalConfigPath = path.join(testDir, 'e2b.toml') + + expect( + await fs + .access(originalDockerfilePath) + .then(() => true) + .catch(() => false) + ).toBe(false) + expect( + await fs + .access(originalConfigPath) + .then(() => true) + .catch(() => false) + ).toBe(false) + } + + async function copyFixtureFiles(fixtureDir: string, targetDir: string) { + // Copy Dockerfile and config + await fs.copyFile( + path.join(fixtureDir, 'e2b.Dockerfile'), + path.join(targetDir, 'e2b.Dockerfile') + ) + await fs.copyFile( + path.join(fixtureDir, 'e2b.toml'), + path.join(targetDir, 'e2b.toml') + ) + } + }) + + describe('Override Options', () => { + test('should apply name, command and resource overrides', async () => { + const dockerfile = 'FROM node:18' + await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), dockerfile) + + const config = `template_id = "config-name" +dockerfile = "e2b.Dockerfile" +cpu_count = 2 +memory_mb = 512` + await fs.writeFile(path.join(testDir, 'e2b.toml'), config) + + execSync( + `node ${cliPath} template migrate --language typescript --name " Overridden-Name " --cmd "node server.js" --ready-cmd "curl localhost:3000" --cpu-count 4 --memory-mb 2048`, + { + cwd: testDir, + } + ) + + const templateFile = await fs.readFile( + path.join(testDir, 'template.ts'), + 'utf-8' + ) + expect(templateFile).toContain( + ".setStartCmd('sudo node server.js', 'curl localhost:3000')" + ) + + const buildProdFile = await fs.readFile( + path.join(testDir, 'build.prod.ts'), + 'utf-8' + ) + expect(buildProdFile).toContain("'overridden-name'") + expect(buildProdFile).not.toContain("'config-name'") + expect(buildProdFile).toContain('cpuCount: 4') + expect(buildProdFile).toContain('memoryMB: 2048') + }) + + test('should reject an invalid --name', async () => { + const dockerfile = 'FROM node:18' + await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), dockerfile) + + const config = `template_id = "config-name" +dockerfile = "e2b.Dockerfile"` + await fs.writeFile(path.join(testDir, 'e2b.toml'), config) + + expect(() => { + execSync( + `node ${cliPath} template migrate --language typescript --name "Invalid Name"`, + { + cwd: testDir, + } + ) + }).toThrow() + }) + + test('should reject non-numeric resource overrides', async () => { + const dockerfile = 'FROM node:18' + await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), dockerfile) + + const config = `template_id = "config-name" +dockerfile = "e2b.Dockerfile"` + await fs.writeFile(path.join(testDir, 'e2b.toml'), config) + + expect(() => { + execSync( + `node ${cliPath} template migrate --language typescript --cpu-count abc`, + { + cwd: testDir, + } + ) + }).toThrow() + + expect(() => { + execSync( + `node ${cliPath} template migrate --language typescript --memory-mb abc`, + { + cwd: testDir, + } + ) + }).toThrow() + }) + + test('should reject an odd memory override', async () => { + const dockerfile = 'FROM node:18' + await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), dockerfile) + + const config = `template_id = "resources" +dockerfile = "e2b.Dockerfile"` + await fs.writeFile(path.join(testDir, 'e2b.toml'), config) + + expect(() => { + execSync( + `node ${cliPath} template migrate --language typescript --memory-mb 1023`, + { + cwd: testDir, + } + ) + }).toThrow() + }) + }) + + describe('Error Cases', () => { + test('should succeed with warning when config file is missing', async () => { + // Create only Dockerfile, no config + const dockerfile = 'FROM node:18' + await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), dockerfile) + + // Run migration and expect it to succeed with warning (capture stderr + stdout) + const output = execSync( + `node ${cliPath} template migrate --language typescript 2>&1`, + { + cwd: testDir, + encoding: 'utf-8', + } + ) + + expect(output).toContain( + 'Config file ./e2b.toml not found. Using defaults.' + ) + expect(output).toContain('Migration completed successfully') + }) + + test('should fail gracefully when Dockerfile is missing', async () => { + // Create only config, no Dockerfile + const config = `template_id = "test-app" +dockerfile = "e2b.Dockerfile"` + await fs.writeFile(path.join(testDir, 'e2b.toml'), config) + + // Run migration and expect it to fail + expect(() => { + execSync(`node ${cliPath} template migrate --language typescript`, { + cwd: testDir, + }) + }).toThrow() + }) + }) + + describe('File Collision Handling', () => { + test('should error out if files already exist', async () => { + // Create test Dockerfile + const dockerfile = 'FROM node:18' + await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), dockerfile) + + // Create test config + const config = `template_id = "test-app" +dockerfile = "e2b.Dockerfile"` + await fs.writeFile(path.join(testDir, 'e2b.toml'), config) + + // Create existing files + await fs.writeFile(path.join(testDir, 'template.ts'), '// existing') + await fs.writeFile(path.join(testDir, 'build.dev.ts'), '// existing') + await fs.writeFile(path.join(testDir, 'build.prod.ts'), '// existing') + + // Run migration + expect(() => { + execSync(`node ${cliPath} template migrate --language typescript`, { + cwd: testDir, + }) + }).toThrow() + + const files = await fs.readdir(testDir) + expect(files).toContain('template.ts') + expect(files).toContain('build.dev.ts') + expect(files).toContain('build.prod.ts') + }) + }) +}) diff --git a/packages/cli/tests/commands/template/publish.test.ts b/packages/cli/tests/commands/template/publish.test.ts new file mode 100644 index 0000000..d90e700 --- /dev/null +++ b/packages/cli/tests/commands/template/publish.test.ts @@ -0,0 +1,74 @@ +import * as fs from 'fs/promises' +import * as path from 'path' +import { execSync } from 'child_process' +import { afterEach, beforeEach, describe, expect, test } from 'vitest' + +describe('Template Publish --config', () => { + let testDir: string + + beforeEach(async () => { + testDir = await fs.mkdtemp('e2b-publish-config-test-') + + const defaultConfig = `template_id = "default-template-id" +dockerfile = "e2b.Dockerfile"` + await fs.writeFile(path.join(testDir, 'e2b.toml'), defaultConfig) + + const customConfig = `template_id = "custom-template-id" +dockerfile = "e2b.Dockerfile"` + await fs.writeFile(path.join(testDir, 'custom.toml'), customConfig) + + await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), 'FROM alpine:3.18') + }) + + afterEach(async () => { + if (testDir) { + await fs.rm(testDir, { recursive: true, force: true }) + } + }) + + test('uses the config file passed via --config even when e2b.toml exists', async () => { + const cliPath = path.join(process.cwd(), 'dist', 'index.js') + + let output = '' + try { + // This will likely fail on the network call, but we only need stdout up to that point + output = execSync( + `node "${cliPath}" template publish --yes --path "${testDir}" --config custom.toml 2>&1`, + { encoding: 'utf-8', stdio: 'pipe', timeout: 10_000 } + ) + } catch (err: any) { + // Capture partial output from the failed process (expected due to no network) + output = (err?.stdout ?? '') + (err?.stderr ?? '') + } + + // It should log the sandbox template selection with the custom template ID and path + expect(output).toContain('Sandbox templates to publish') + expect(output).toContain('custom-template-id') + expect(output).toContain('custom.toml') + // And should not show the default template id when custom is provided + expect(output).not.toContain('default-template-id') + }) + + test('uses the default e2b.toml when --config is not provided', async () => { + const cliPath = path.join(process.cwd(), 'dist', 'index.js') + + let output = '' + try { + output = execSync( + `node "${cliPath}" template publish --yes --path "${testDir}" 2>&1`, + { encoding: 'utf-8', stdio: 'pipe', timeout: 10_000 } + ) + } catch (err: any) { + output = (err?.stdout ?? '') + (err?.stderr ?? '') + } + + // Should reference the default config and template id + expect(output).toContain('Sandbox templates to publish') + expect(output).toContain('default-template-id') + expect(output).toContain('e2b.toml') + // Should not mention the custom config/template id + expect(output).not.toContain('custom-template-id') + }) +}) + + diff --git a/packages/cli/tests/setup.ts b/packages/cli/tests/setup.ts new file mode 100644 index 0000000..32c4633 --- /dev/null +++ b/packages/cli/tests/setup.ts @@ -0,0 +1,102 @@ +import { execSync, spawn, spawnSync } from 'node:child_process' +import path from 'node:path' + +export const isDebug = process.env.E2B_DEBUG !== undefined + +type CliRunOptions = { + timeoutMs: number + env?: NodeJS.ProcessEnv +} + +type CliRunSyncOptions = CliRunOptions & { + input?: string | Buffer +} + +export type CliRunResult = { + status: number | null + stdout: Buffer + stderr: Buffer + error?: Error +} + +const cliPath = path.join(process.cwd(), 'dist', 'index.js') + +export async function setup() { + execSync('pnpm build', { stdio: 'inherit' }) +} + +export function runCli( + args: string[], + options: CliRunSyncOptions +): ReturnType { + return spawnSync('node', [cliPath, ...args], { + env: options.env ?? process.env, + input: options.input, + encoding: 'utf8', + timeout: options.timeoutMs, + }) +} + +export async function runCliWithPipedStdin( + args: string[], + input: Buffer, + options: CliRunOptions +): Promise { + return await new Promise((resolve) => { + const child = spawn('node', [cliPath, ...args], { + env: options.env ?? process.env, + stdio: ['pipe', 'pipe', 'pipe'], + }) + + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + let childError: Error | undefined + let timedOut = false + + const timer = setTimeout(() => { + timedOut = true + child.kill() + }, options.timeoutMs) + + child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk))) + child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk))) + child.on('error', (err) => { + childError = err + }) + child.on('close', (code) => { + clearTimeout(timer) + const timeoutError = timedOut + ? Object.assign(new Error('CLI command timed out'), { + code: 'ETIMEDOUT', + } as NodeJS.ErrnoException) + : undefined + + resolve({ + status: code, + stdout: Buffer.concat(stdoutChunks), + stderr: Buffer.concat(stderrChunks), + error: childError ?? timeoutError, + }) + }) + + child.stdin.write(input) + child.stdin.end() + }) +} + +export function bufferToText(value: Buffer | string | null | undefined): string { + if (!value) { + return '' + } + return typeof value === 'string' ? value : value.toString('utf8') +} + +export function parseEnvInt(name: string, fallback: number): number { + const raw = process.env[name] + if (!raw) { + return fallback + } + + const parsed = Number.parseInt(raw, 10) + return Number.isFinite(parsed) ? parsed : fallback +} diff --git a/packages/cli/tests/user_config_permissions.test.ts b/packages/cli/tests/user_config_permissions.test.ts new file mode 100644 index 0000000..08b616f --- /dev/null +++ b/packages/cli/tests/user_config_permissions.test.ts @@ -0,0 +1,52 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { writeUserConfig, type UserConfig } from '../src/user' + +const tmpDirs: string[] = [] +const isPosix = process.platform !== 'win32' + +afterEach(() => { + for (const tmpDir of tmpDirs.splice(0)) { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } +}) + +describe('writeUserConfig', () => { + it('stores API credentials in an owner-only config file and directory', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'e2b-config-perms-')) + tmpDirs.push(tmpDir) + const configPath = path.join(tmpDir, '.e2b', 'config.json') + const config: UserConfig = { + version: 1, + identity: { + email: 'victim@example.com', + }, + oauth: { + token_endpoint: 'https://hydra.example.com/oauth2/token', + revoke_endpoint: 'https://hydra.example.com/oauth2/revoke', + client_id: 'cli-client-id', + }, + tokens: { + access_token: 'access-token-secret', + refresh_token: 'refresh-token-secret', + }, + last_refresh: '2024-06-24T12:00:00.000Z', + teamName: 'default', + teamId: 'team-id', + teamApiKey: 'team-api-key-secret', + } + + writeUserConfig(configPath, config) + + // POSIX permission bits (chmod/stat.mode) are not reliably preserved on + // Windows, where Node reports broad Windows-derived modes regardless of + // the chmod call. Only assert the 0o700/0o600 masks on POSIX platforms. + if (isPosix) { + expect(fs.statSync(path.dirname(configPath)).mode & 0o777).toBe(0o700) + expect(fs.statSync(configPath).mode & 0o777).toBe(0o600) + } + expect(JSON.parse(fs.readFileSync(configPath, 'utf8'))).toEqual(config) + }) +}) diff --git a/packages/cli/tests/utils/errors.test.ts b/packages/cli/tests/utils/errors.test.ts new file mode 100644 index 0000000..ed3d2c0 --- /dev/null +++ b/packages/cli/tests/utils/errors.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'vitest' + +import { handleE2BRequestError, E2BRequestError } from '../../src/utils/errors' + +describe('handleE2BRequestError', () => { + test('does not throw when there is no error', () => { + const res = { data: { id: '123' } } + expect(() => handleE2BRequestError(res)).not.toThrow() + }) + + test('throws E2BRequestError for known status codes', () => { + const res = { error: { code: 401, message: 'invalid token' } } + expect(() => handleE2BRequestError(res, 'Auth failed')).toThrow( + E2BRequestError + ) + expect(() => handleE2BRequestError(res, 'Auth failed')).toThrow( + 'Auth failed: [401] unauthorized: invalid token' + ) + }) + + test('throws E2BRequestError with message for status code 0', () => { + const res = { error: { code: 0, message: 'connection reset' } } + expect(() => handleE2BRequestError(res, 'Request failed')).toThrow( + E2BRequestError + ) + expect(() => handleE2BRequestError(res, 'Request failed')).toThrow( + 'Request failed: [0] unknown error: connection reset' + ) + }) + + test('throws E2BRequestError when error code is missing', () => { + const res = { error: { message: 'something went wrong' } } as any + expect(() => handleE2BRequestError(res, 'Request failed')).toThrow( + E2BRequestError + ) + expect(() => handleE2BRequestError(res, 'Request failed')).toThrow( + 'Request failed: [0] unknown error: something went wrong' + ) + }) + + test('handles valid but unlisted HTTP status codes via statuses package', () => { + const res = { error: { code: 502, message: 'upstream down' } } + expect(() => handleE2BRequestError(res)).toThrow(E2BRequestError) + expect(() => handleE2BRequestError(res)).toThrow( + '[502] Bad Gateway: upstream down' + ) + }) +}) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..ae2834d --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "lib": ["es2022", "dom", "dom.iterable"], + "allowJs": true, + "declaration": true, + "declarationMap": true, + "moduleResolution": "bundler", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "preserveSymlinks": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "rootDirs": ["src"], + "paths": { + "src": ["./src/index.ts"], + "src/*": ["./src/*"], + "e2b": ["../js-sdk/src"] + } + }, + "exclude": ["dist", "node_modules"] +} diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts new file mode 100644 index 0000000..98a0d56 --- /dev/null +++ b/packages/cli/tsdown.config.ts @@ -0,0 +1,32 @@ +import { createRequire } from 'node:module' + +import { defineConfig } from 'tsdown' + +const require = createRequire(import.meta.url) +const packageJSON = require('./package.json') as { + dependencies: Record +} + +const excludedPackages = ['inquirer'] + +export default defineConfig({ + entry: ['src/index.ts'], + shims: true, // Needed for "open" package, as it uses import.meta.url and we are building for cjs + target: 'node20', + platform: 'node', + format: 'cjs', + // Emit the bin as dist/index.js (matches the package.json "bin" entry) + fixedExtension: false, + // The CLI is an executable, it doesn't ship type declarations + dts: false, + sourcemap: true, + clean: true, + deps: { + // Bundle all runtime dependencies (except those that must stay external) + alwaysBundle: Object.keys(packageJSON.dependencies).filter( + (f) => !excludedPackages.includes(f) + ), + }, + // Copy template files to dist/templates + copy: [{ from: 'src/templates', to: 'dist' }], +}) diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..01f8d84 --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config' +import path from 'path' + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + testTimeout: 30_000, + include: ['tests/**/*.test.ts'], + exclude: ['node_modules', 'dist', 'testground'], + globalSetup: ['tests/setup.ts'], + }, + resolve: { + alias: { + src: path.resolve(__dirname, './src'), + }, + }, +}) diff --git a/packages/connect-python/.gitignore b/packages/connect-python/.gitignore new file mode 100644 index 0000000..ba077a4 --- /dev/null +++ b/packages/connect-python/.gitignore @@ -0,0 +1 @@ +bin diff --git a/packages/connect-python/LICENSE b/packages/connect-python/LICENSE new file mode 100644 index 0000000..8b55ffa --- /dev/null +++ b/packages/connect-python/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 2021-2024 The Connect Authors + + 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/packages/connect-python/Makefile b/packages/connect-python/Makefile new file mode 100644 index 0000000..6782886 --- /dev/null +++ b/packages/connect-python/Makefile @@ -0,0 +1,29 @@ +PY = python -m + +plugin = protoc-gen-connect-python + +dev: + $(PY) pip install -r requirements-dev.txt + +fmt: + $(PY) ruff format src + +lint: + $(PY) ruff check src + +clean: + rm -f bin/protoc-gen-connect-python + rm -rf dist + +upload: clean + $(PY) build + $(PY) twine upload --repository=connect-python dist/* + + +bin/$(plugin): $(wildcard cmd/$(plugin)/*.go) pyproject.toml Makefile + go install -ldflags "-w -s" ./cmd/$(plugin) + +.PHONY: dev fmt lint upload clean build + +build: + make bin/protoc-gen-connect-python diff --git a/packages/connect-python/README.md b/packages/connect-python/README.md new file mode 100644 index 0000000..b84611d --- /dev/null +++ b/packages/connect-python/README.md @@ -0,0 +1,7 @@ +🚧 Currently pending [an open RFC to be moved into the Connect RPC org](https://github.com/connectrpc/connectrpc.com/pull/71). Please show support. 🚧 + +--- + +# connect-python + +Python client implementation for the [Connect](https://connect.build) RPC protocol. diff --git a/packages/connect-python/cmd/protoc-gen-connect-python/main.go b/packages/connect-python/cmd/protoc-gen-connect-python/main.go new file mode 100644 index 0000000..aa92ce6 --- /dev/null +++ b/packages/connect-python/cmd/protoc-gen-connect-python/main.go @@ -0,0 +1,367 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + log "golang.org/x/exp/slog" + "google.golang.org/protobuf/proto" + descriptor "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/pluginpb" +) + +const pluginVersion = "0.1.0.dev2" + +func init() { + ll := log.New(log.NewTextHandler(os.Stderr, &log.HandlerOptions{ + Level: log.LevelDebug, + ReplaceAttr: func(groups []string, a log.Attr) log.Attr { + if a.Key == log.TimeKey && len(groups) == 0 { + return log.Attr{} + } + return a + }, + })) + log.SetDefault(ll.With(log.Int("pid", os.Getpid()))) +} + +func main() { + if len(os.Args) == 2 && os.Args[1] == "--version" { + fmt.Fprintln(os.Stdout, pluginVersion) + os.Exit(0) + } + + f := func(plugin *Plugin) error { + for _, f := range plugin.filesToGenerate { + generate(plugin, f) + } + return nil + } + if err := run(f); err != nil { + fmt.Fprintf(os.Stderr, "%s: %v\n", filepath.Base(os.Args[0]), err) + os.Exit(1) + } +} + +func run(f func(*Plugin) error) error { + in, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + req := &pluginpb.CodeGeneratorRequest{} + if err := proto.Unmarshal(in, req); err != nil { + return err + } + gen, err := newPlugin(req) + if err != nil { + return err + } + if err := f(gen); err != nil { + gen.Error(err) + } + resp := gen.Response() + out, err := proto.Marshal(resp) + if err != nil { + return err + } + _, err = os.Stdout.Write(out) + return err +} + +func newPlugin(req *pluginpb.CodeGeneratorRequest) (*Plugin, error) { + gen := &Plugin{ + request: req, + filesByPackage: make(map[string]*descriptor.FileDescriptorProto), + filesByPath: make(map[string]*descriptor.FileDescriptorProto), + messagesByType: make(map[string]*descriptor.DescriptorProto), + } + + for _, f := range gen.request.ProtoFile { + name := f.GetName() + + pkg := f.GetPackage() + log.Debug("ProtoFile", + log.String("name", name), + log.String("pkg", pkg), + ) + // if _, ok := gen.filesByPackage[pkg]; ok { + // return nil, fmt.Errorf("duplicate package: %q", name) + // } + gen.filesByPackage[pkg] = f + if _, ok := gen.filesByPath[name]; ok { + return nil, fmt.Errorf("duplicate file name: %q", name) + } + gen.filesByPath[name] = f + for _, msg := range f.GetMessageType() { + msgKey := f.GetPackage() + "." + msg.GetName() + if _, ok := gen.messagesByType[msgKey]; ok { + return nil, fmt.Errorf("duplicate message: %q", msgKey) + } + gen.messagesByType[msgKey] = msg + log.Debug("MessageType", + log.String("name", msgKey), + ) + } + gen.files = append(gen.files, f) + } + + for _, name := range req.FileToGenerate { + if f, ok := gen.filesByPath[name]; ok { + log.Debug("FileToGenerate", + log.String("name", name), + log.Any("deps", f.Dependency), + log.Int("services", len(f.Service)), + ) + if len(f.Service) > 0 { + gen.filesToGenerate = append(gen.filesToGenerate, f) + } + } else { + return nil, fmt.Errorf("missing file: %q", name) + } + } + + return gen, nil +} + +type Plugin struct { + request *pluginpb.CodeGeneratorRequest + + files []*descriptor.FileDescriptorProto + filesByPackage map[string]*descriptor.FileDescriptorProto + filesByPath map[string]*descriptor.FileDescriptorProto + messagesByType map[string]*descriptor.DescriptorProto + filesToGenerate []*descriptor.FileDescriptorProto + + generatedFiles []*pluginpb.CodeGeneratorResponse_File + + err error +} + +func (gen *Plugin) Response() *pluginpb.CodeGeneratorResponse { + resp := &pluginpb.CodeGeneratorResponse{} + if gen.err != nil { + resp.Error = Ptr(gen.err.Error()) + return resp + } + resp.File = gen.generatedFiles + return resp +} + +func (gen *Plugin) Error(err error) { + if gen.err == nil { + gen.err = err + } +} + +func Ptr[T any](v T) *T { + return &v +} + +func print(buf *strings.Builder, tpl string, args ...interface{}) { + buf.WriteString(fmt.Sprintf(tpl, args...)) + buf.WriteByte('\n') +} + +func getPackage(path string) string { + return strings.ReplaceAll(filepath.Dir(path), "/", ".") +} + +func getModule(path string) string { + path = filepath.Base(path) + ext := filepath.Ext(path) + return strings.TrimSuffix(path, ext) +} + +func getProtoModule(path string) string { + return getModule(path) + "_pb2" +} + +func getConnectModule(path string) string { + return getModule(path) + "_connect" +} + +func getProtoModuleAlias(path string) string { + path = getPackage(path) + "." + getProtoModule(path) + path = strings.ReplaceAll(path, "_", "__") + path = strings.ReplaceAll(path, ".", "_dot_") + return path +} + +func getServiceName(svc *descriptor.ServiceDescriptorProto) string { + return svc.GetName() + "Name" +} + +func getServiceClient(svc *descriptor.ServiceDescriptorProto) string { + return svc.GetName() + "Client" +} + +func getServiceBasePath(file *descriptor.FileDescriptorProto, svc *descriptor.ServiceDescriptorProto) string { + return file.GetPackage() + "." + svc.GetName() +} + +func getMethodProperty(m *descriptor.MethodDescriptorProto) string { + return "_" + toSnakeCase(m.GetName()) +} + +func getMethodType(m *descriptor.MethodDescriptorProto) string { + switch { + case m.GetClientStreaming() && m.GetServerStreaming(): + return "bidi_stream" + case m.GetClientStreaming(): + return "client_stream" + case m.GetServerStreaming(): + return "server_stream" + default: + return "unary" + } +} + +func splitPackageType(path string) (string, string) { + lastDot := strings.LastIndexByte(path, '.') + return path[:lastDot], path[lastDot+1:] +} + +func resolveMessageFromMethod(gen *Plugin, m *descriptor.MethodDescriptorProto) (string, string) { + fullyQualifiedName := m.GetOutputType()[1:] // strip prefixed "." + pkgName, msgName := splitPackageType(fullyQualifiedName) + filename := gen.filesByPackage[pkgName].GetName() + return filename, msgName +} + +func resolveInputFromMethod(gen *Plugin, m *descriptor.MethodDescriptorProto) (string, string) { + fullyQualifiedName := m.GetInputType()[1:] // strip prefixed "." + pkgName, msgName := splitPackageType(fullyQualifiedName) + filename := gen.filesByPackage[pkgName].GetName() + return filename, msgName +} + +func getResponseType(gen *Plugin, m *descriptor.MethodDescriptorProto) string { + filename, msgName := resolveMessageFromMethod(gen, m) + + log.Debug("ResponseType", + log.String("msg", msgName), + log.String("import", filename), + log.String("alias", getProtoModuleAlias(filename)), + ) + return getProtoModuleAlias(filename) + "." + msgName +} + +func getRequestType(gen *Plugin, m *descriptor.MethodDescriptorProto) string { + filename, msgName := resolveInputFromMethod(gen, m) + + log.Debug("RequestType", + log.String("msg", msgName), + log.String("import", filename), + log.String("alias", getProtoModuleAlias(filename)), + ) + return getProtoModuleAlias(filename) + "." + msgName +} + +var ( + matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") + matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])") +) + +func toSnakeCase(str string) string { + snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}") + snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}") + return strings.ToLower(snake) +} + +func generate(gen *Plugin, file *descriptor.FileDescriptorProto) { + filename := file.GetName() + + dir := filepath.Dir(filename) + pkgName := getPackage(filename) + modName := getModule(filename) + + log.Debug("Generate", + log.String("name", filename), + log.String("pkg", pkgName), + log.String("mod", modName), + ) + + b := new(strings.Builder) + + depsUniq := make(map[string]struct{}) + for _, svc := range file.Service { + for _, method := range svc.Method { + filename, _ := resolveMessageFromMethod(gen, method) + depsUniq[filename] = struct{}{} + } + } + + deps := make([]string, 0, len(depsUniq)) + for dep := range depsUniq { + deps = append(deps, dep) + } + sort.Strings(deps) + + print(b, "# Code generated by protoc-gen-connect-python %s, DO NOT EDIT.", pluginVersion) + + print(b, "from typing import Any, Generator, Coroutine, AsyncGenerator, Optional") + print(b, "from httpcore import ConnectionPool, AsyncConnectionPool") + print(b, "") + + print(b, "import e2b_connect as connect") + if len(deps) > 0 { + print(b, "") + for _, dep := range deps { + print(b, "from %s import %s as %s", getPackage(dep), getProtoModule(dep), getProtoModuleAlias(dep)) + } + } + print(b, "") + + for _, svc := range file.Service { + print(b, `%s = "%s"`, getServiceName(svc), getServiceBasePath(file, svc)) + } + + for _, svc := range file.Service { + print(b, "") + print(b, "") + print(b, `class %s:`, getServiceClient(svc)) + print(b, " def __init__(self, base_url: str, *, pool: Optional[ConnectionPool] = None, async_pool: Optional[AsyncConnectionPool] = None, compressor=None, json=False, **opts):") + if len(svc.Method) == 0 { + print(b, " pass") + continue + } + for _, method := range svc.Method { + print(b, " self.%s = connect.Client(", getMethodProperty(method)) + print(b, " pool=pool,") + print(b, " async_pool=async_pool,") + print(b, ` url=f"{base_url}/{%s}/%s",`, getServiceName(svc), method.GetName()) + print(b, ` response_type=%s,`, getResponseType(gen, method)) + print(b, ` compressor=compressor,`) + print(b, ` json=json,`) + print(b, ` **opts`) + print(b, " )") + } + for _, method := range svc.Method { + print(b, "") + + if method.GetServerStreaming() { + print(b, " def %s(self, req: %s , **opts) -> Generator[%s, Any, None]:", toSnakeCase(method.GetName()), getRequestType(gen, method), getResponseType(gen, method)) + print(b, " return self.%s.call_%s(req, **opts)", getMethodProperty(method), getMethodType(method)) + print(b, "") + print(b, " def a%s(self, req: %s , **opts) -> AsyncGenerator[%s, Any]:", toSnakeCase(method.GetName()), getRequestType(gen, method), getResponseType(gen, method)) + print(b, " return self.%s.acall_%s(req, **opts)", getMethodProperty(method), getMethodType(method)) + } else { + print(b, " def %s(self, req: %s, **opts) -> %s:", toSnakeCase(method.GetName()), getRequestType(gen, method), getResponseType(gen, method)) + print(b, " return self.%s.call_%s(req, **opts)", getMethodProperty(method), getMethodType(method)) + print(b, "") + print(b, " def a%s(self, req: %s, **opts) -> Coroutine[Any, Any, %s]:", toSnakeCase(method.GetName()), getRequestType(gen, method), getResponseType(gen, method)) + print(b, " return self.%s.acall_%s(req, **opts)", getMethodProperty(method), getMethodType(method)) + } + } + } + + gen.generatedFiles = append(gen.generatedFiles, &pluginpb.CodeGeneratorResponse_File{ + Name: Ptr(filepath.Join(dir, getConnectModule(filename)+".py")), + Content: Ptr(b.String()), + }) +} diff --git a/packages/connect-python/go.mod b/packages/connect-python/go.mod new file mode 100644 index 0000000..b08c677 --- /dev/null +++ b/packages/connect-python/go.mod @@ -0,0 +1,8 @@ +module go.withmatt.com/connect-python + +go 1.22 + +require ( + golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f + google.golang.org/protobuf v1.33.0 +) diff --git a/packages/connect-python/go.sum b/packages/connect-python/go.sum new file mode 100644 index 0000000..5e5f352 --- /dev/null +++ b/packages/connect-python/go.sum @@ -0,0 +1,6 @@ +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY= +golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= diff --git a/packages/connect-python/pyproject.toml b/packages/connect-python/pyproject.toml new file mode 100644 index 0000000..5e10909 --- /dev/null +++ b/packages/connect-python/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "connect-python" +version = "0.1.0.dev2" +authors = [{ email = "matt@ydekproductions.com" }] +description = "Client implementation for the Connect RPC protocol" +readme = "README.md" +requires-python = ">=3.12" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", +] +dependencies = [ + "protobuf", + "httpcore", +] + +[tool.hatch.build] +include = [ + "/src", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/connect"] + +[project.urls] +"Homepage" = "https://github.com/mattrobenolt/connect-python" +"Bug Tracker" = "https://github.com/mattrobenolt/connect-python/issues" + +[project.optional-dependencies] +http2 = ["h2"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/packages/connect-python/requirements-dev.txt b/packages/connect-python/requirements-dev.txt new file mode 100644 index 0000000..3b7a0da --- /dev/null +++ b/packages/connect-python/requirements-dev.txt @@ -0,0 +1,5 @@ +-e .[http2] + +ruff +build +twine diff --git a/packages/js-sdk/.gitignore b/packages/js-sdk/.gitignore new file mode 100644 index 0000000..bc728af --- /dev/null +++ b/packages/js-sdk/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# generate output +dist + +# compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +/test.md +logs.txt +deno.lock + +__screenshots__ diff --git a/packages/js-sdk/.prettierignore b/packages/js-sdk/.prettierignore new file mode 100644 index 0000000..655f26c --- /dev/null +++ b/packages/js-sdk/.prettierignore @@ -0,0 +1 @@ +schema.gen.ts diff --git a/packages/js-sdk/LICENSE b/packages/js-sdk/LICENSE new file mode 100644 index 0000000..d7beaa9 --- /dev/null +++ b/packages/js-sdk/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2025 FOUNDRYLABS, INC. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/js-sdk/README.md b/packages/js-sdk/README.md new file mode 100644 index 0000000..463e923 --- /dev/null +++ b/packages/js-sdk/README.md @@ -0,0 +1,68 @@ +

+ + + + E2B Logo + +

+ +

+ + Last 1 month downloads for the JavaScript SDK + +

+ + +## What is E2B? +[E2B](https://www.e2b.dev/) is an open-source infrastructure that allows you to run AI-generated code in secure isolated sandboxes in the cloud. To start and control sandboxes, use our [JavaScript SDK](https://www.npmjs.com/package/e2b) or [Python SDK](https://pypi.org/project/e2b). + +## Run your first Sandbox + +### 1. Install SDK + +```bash +npm i e2b +``` + +### 2. Get your E2B API key +1. Sign up to E2B [here](https://e2b.dev). +2. Get your API key [here](https://e2b.dev/dashboard?tab=keys). +3. Set environment variable with your API key +``` +E2B_API_KEY=e2b_*** +``` + +### 3. Start a sandbox and run commands + +```ts +import Sandbox from 'e2b' + +const sandbox = await Sandbox.create() +const result = await sandbox.commands.run('echo "Hello from E2B!"') +console.log(result.stdout) // Hello from E2B! +``` + +### 4. Code execution with Code Interpreter + +If you need [`runCode()`](https://e2b.dev/docs/code-interpreting), install the [Code Interpreter SDK](https://github.com/e2b-dev/code-interpreter): + +```bash +npm i @e2b/code-interpreter +``` + +```ts +import { Sandbox } from '@e2b/code-interpreter' + +const sandbox = await Sandbox.create() +const execution = await sandbox.runCode('x = 1; x += 1; x') +console.log(execution.text) // outputs 2 +``` + +### 5. Check docs +Visit [E2B documentation](https://e2b.dev/docs). + +### 6. E2B cookbook +Visit our [Cookbook](https://github.com/e2b-dev/e2b-cookbook/tree/main) to get inspired by examples with different LLMs and AI frameworks. diff --git a/packages/js-sdk/example.mts b/packages/js-sdk/example.mts new file mode 100644 index 0000000..4faac36 --- /dev/null +++ b/packages/js-sdk/example.mts @@ -0,0 +1,11 @@ +import { Sandbox } from './dist' +import { configDotenv } from 'dotenv' + +configDotenv() + +const sandbox = await Sandbox.create() + +console.log('Sandbox created:', sandbox.sandboxId) + +await sandbox.kill() +console.log('Sandbox killed') diff --git a/packages/js-sdk/package.json b/packages/js-sdk/package.json new file mode 100644 index 0000000..8a81da4 --- /dev/null +++ b/packages/js-sdk/package.json @@ -0,0 +1,109 @@ +{ + "name": "e2b", + "version": "2.32.0", + "description": "E2B SDK that give agents cloud environments", + "homepage": "https://e2b.dev", + "license": "MIT", + "author": { + "name": "FoundryLabs, Inc.", + "email": "hello@e2b.dev", + "url": "https://e2b.dev" + }, + "bugs": "https://github.com/e2b-dev/e2b/issues", + "repository": { + "type": "git", + "url": "https://github.com/e2b-dev/e2b", + "directory": "packages/js-sdk" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "scripts": { + "prepublishOnly": "pnpm build", + "build": "tsc --noEmit && tsdown", + "dev": "tsdown --watch", + "example": "tsx example.mts", + "test": "vitest run", + "generate": "npm-run-all generate:* && pnpm run format", + "generate:api": "python ./../../spec/remove_extra_tags.py sandboxes snapshots templates tags auth volumes && openapi-typescript ../../spec/openapi_generated.yml -x api_key --array-length --alphabetize --default-non-nullable false --output src/api/schema.gen.ts", + "generate:envd": "cd ../../spec/envd && buf generate --template buf-js.gen.yaml\n", + "generate:envd-api": "openapi-typescript ../../spec/envd/envd.yaml -x api_key --array-length --alphabetize --output src/envd/schema.gen.ts", + "generate:volume-api": "openapi-typescript ../../spec/openapi-volumecontent.yml -x api_key --array-length --alphabetize --output src/volume/schema.gen.ts", + "generate:mcp": "json2ts -i ./../../spec/mcp-server.json -o src/sandbox/mcp.d.ts --unreachableDefinitions --style.singleQuote --no-style.semi", + "check-deps": "knip", + "pretest": "npx playwright install --with-deps chromium", + "postPublish": "./scripts/post-publish.sh || true", + "test:bun": "bun test tests/runtimes/bun --env-file=.env", + "test:deno": "deno test tests/runtimes/deno/ --allow-net --allow-read --allow-env --unstable-sloppy-imports --trace-leaks", + "test:integration": "E2B_INTEGRATION_TEST=1 vitest run tests/integration/**", + "typecheck": "tsc --noEmit", + "lint": "oxlint --config ../../.oxlintrc.json src tests", + "format": "prettier --write src/ tests/ example.mts" + }, + "devDependencies": { + "@testing-library/react": "^16.2.0", + "@types/node": "^20.19.19", + "@types/platform": "^1.3.6", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@typescript/native": "npm:typescript@^7.0.2", + "@vitejs/plugin-react": "^4.3.4", + "@vitest/browser": "^4.1.0", + "@vitest/browser-playwright": "^4.1.0", + "dotenv": "^16.4.5", + "json-schema-to-typescript": "^15.0.4", + "knip": "^5.43.6", + "msw": "^2.12.10", + "npm-run-all": "^4.1.5", + "openapi-typescript": "^7.9.1", + "playwright": "^1.55.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tsdown": "^0.22.3", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "vitest": "^4.1.8", + "vitest-browser-react": "^2.2.0" + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "keywords": [ + "e2b", + "ai-agents", + "agents", + "ai", + "code-interpreter", + "sandbox", + "code", + "runtime", + "vm", + "nodejs", + "javascript", + "typescript" + ], + "dependencies": { + "@bufbuild/protobuf": "^2.12.1", + "@connectrpc/connect": "^2.1.2", + "@connectrpc/connect-web": "^2.1.2", + "chalk": "^5.3.0", + "compare-versions": "^6.1.0", + "dockerfile-ast": "^0.7.1", + "glob": "^11.1.0", + "openapi-fetch": "^0.14.1", + "platform": "^1.3.6", + "tar": "^7.5.16", + "undici": "^7.28.0" + }, + "engines": { + "node": ">=20.18.1 <21 || >=22" + }, + "browserslist": [ + "defaults" + ] +} diff --git a/packages/js-sdk/scripts/post-publish.sh b/packages/js-sdk/scripts/post-publish.sh new file mode 100755 index 0000000..3c863ed --- /dev/null +++ b/packages/js-sdk/scripts/post-publish.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +npm pkg set 'name'='@e2b/sdk' +npm publish --no-git-checks +npm pkg set 'name'='e2b' +npm deprecate "@e2b/sdk@$(npm pkg get version | tr -d \")" "The package @e2b/sdk has been renamed to e2b. Please uninstall the old one and install the new by running following command: npm uninstall @e2b/sdk && npm install e2b" diff --git a/packages/js-sdk/src/api/http2.ts b/packages/js-sdk/src/api/http2.ts new file mode 100644 index 0000000..49bf1f6 --- /dev/null +++ b/packages/js-sdk/src/api/http2.ts @@ -0,0 +1,118 @@ +import { runtime } from '../utils' +import { parseInflightLimitEnv, parsePositiveIntEnv } from './metadata' +import { limitConcurrency } from './inflight' +import { + loadUndici, + toUndiciRequestInput, + type UndiciModule, + type UndiciRequestInit, +} from '../undici' + +const DEFAULT_API_CONNECTION_LIMIT = 100 +// 1000 = ~10 streams per connection (with the 100-conn default). +// Override via env if your workload needs different. +const DEFAULT_API_INFLIGHT_LIMIT = 1000 + +// Fetchers are cached per proxy so requests without a proxy keep sharing a +// single dispatcher while each distinct proxy URL gets its own. +const apiFetchers = new Map() + +export function createApiFetch(proxy?: string): typeof fetch { + const key = proxy ?? '' + + const cached = apiFetchers.get(key) + if (cached) { + return cached + } + + const apiFetch = createApiFetchForRuntime(runtime, { proxy }) + apiFetchers.set(key, apiFetch) + + return apiFetch +} + +export function createApiFetchForRuntime( + currentRuntime = runtime, + options: { + connectionLimit?: number + inflightLimit?: number + proxy?: string + loadUndici?: () => Promise + } = {} +): typeof fetch { + if (currentRuntime !== 'node') { + return fetch + } + + let fetcherPromise: Promise | undefined + + return (async (input, init) => { + fetcherPromise ??= buildApiFetcher(options) + const fetcher = await fetcherPromise + + return fetcher(input, init) + }) as typeof fetch +} + +async function buildApiFetcher(options: { + connectionLimit?: number + inflightLimit?: number + proxy?: string + loadUndici?: () => Promise +}): Promise { + const undici = await (options.loadUndici ?? loadUndici)() + const inflightLimit = options.inflightLimit ?? getApiInflightLimit() + + if (!undici) { + return limitConcurrency(fetch, inflightLimit) + } + + const { Agent, ProxyAgent, fetch: undiciFetch } = undici + const connections = options.connectionLimit ?? getApiConnectionLimit() + const dispatcher = options.proxy + ? new ProxyAgent({ + uri: options.proxy, + allowH2: true, + connections, + }) + : new Agent({ + allowH2: true, + connections, + }) + const fetchWithDispatcher = undiciFetch as unknown as ( + input: RequestInfo | URL, + init?: UndiciRequestInit + ) => Promise + + const wrapped: typeof fetch = ((input, init) => { + const request = toUndiciRequestInput(input, init) + + return fetchWithDispatcher(request.input, { + ...request.init, + dispatcher, + }) + }) as typeof fetch + + return limitConcurrency(wrapped, inflightLimit) +} + +export function getApiConnectionLimit(): number { + return parsePositiveIntEnv( + 'E2B_API_CONNECTIONS', + DEFAULT_API_CONNECTION_LIMIT + ) +} + +/** + * Returns the configured max number of API requests that can be in flight at + * once, or `0` to disable the cap. + * + * Defaults to {@link DEFAULT_API_INFLIGHT_LIMIT} ({@link 1000}). Override via + * `E2B_API_INFLIGHT_REQUESTS` env var; set to `0` to disable the cap entirely. + */ +export function getApiInflightLimit(): number { + return parseInflightLimitEnv( + 'E2B_API_INFLIGHT_REQUESTS', + DEFAULT_API_INFLIGHT_LIMIT + ) +} diff --git a/packages/js-sdk/src/api/index.ts b/packages/js-sdk/src/api/index.ts new file mode 100644 index 0000000..a52fe09 --- /dev/null +++ b/packages/js-sdk/src/api/index.ts @@ -0,0 +1,117 @@ +import createClient, { FetchResponse } from 'openapi-fetch' + +import type { components, paths } from './schema.gen' +import { defaultHeaders } from './metadata' +import { createApiFetch } from './http2' +import { ConnectionConfig } from '../connectionConfig' +import { AuthenticationError, RateLimitError, SandboxError } from '../errors' +import { createApiLogger } from '../logs' + +const API_KEY_PATTERN = /^e2b_[0-9a-f]+$/ +const API_KEY_EXAMPLE = `e2b_${'0'.repeat(40)}` + +/** + * Validates that an E2B API key has the expected `e2b_` prefix followed by + * hex characters. Throws `AuthenticationError` otherwise. + */ +export function validateApiKey(apiKey: string): void { + if (!API_KEY_PATTERN.test(apiKey)) { + throw new AuthenticationError( + `Invalid API key format: expected "e2b_" followed by hex characters (e.g. "${API_KEY_EXAMPLE}"). ` + + 'Visit the API Keys tab at https://e2b.dev/dashboard?tab=keys to get your API key.' + ) + } +} + +export function handleApiError( + response: FetchResponse, + errorClass: new ( + message: string, + stackTrace?: string + ) => Error = SandboxError, + stackTrace?: string +): Error | undefined { + // openapi-fetch leaves `error` undefined for non-2xx responses with + // Content-Length: 0, so check the status instead + if (response.response.ok) { + return + } + + if (response.response.status === 401) { + const message = 'Unauthorized, please check your credentials.' + const content = response.error?.message ?? response.error + + if (content) { + return new AuthenticationError(`${message} - ${content}`) + } + return new AuthenticationError(message) + } + + if (response.response.status === 429) { + const message = 'Rate limit exceeded, please try again later' + const content = response.error?.message ?? response.error + + if (content) { + return new RateLimitError(`${message} - ${content}`) + } + return new RateLimitError(message) + } + + const message = + response.error?.message || response.error || response.response.statusText + return new errorClass(`${response.response.status}: ${message}`, stackTrace) +} + +/** + * Client for interacting with the E2B API. + */ +class ApiClient { + readonly api: ReturnType> + + constructor( + config: ConnectionConfig, + opts: { + requireApiKey?: boolean + } = {} + ) { + if ((opts.requireApiKey ?? true) && !config.apiKey) { + throw new AuthenticationError( + 'API key is required, please visit the API Keys tab at https://e2b.dev/dashboard?tab=keys to get your API key. ' + + 'You can either set the environment variable `E2B_API_KEY` ' + + "or you can pass it directly to the sandbox like Sandbox.create({ apiKey: 'e2b_...' })" + ) + } + + if (config.apiKey && config.validateApiKey) { + validateApiKey(config.apiKey) + } + + this.api = createClient({ + baseUrl: config.apiUrl, + fetch: createApiFetch(config.proxy), + // In HTTP 1.1, all connections are considered persistent unless declared otherwise + // keepalive: true, + headers: { + ...defaultHeaders, + ...(config.apiKey && { 'X-API-KEY': config.apiKey }), + ...(config.accessToken && { + Authorization: `Bearer ${config.accessToken}`, + }), + ...config.headers, + }, + querySerializer: { + array: { + style: 'form', + explode: false, + }, + }, + }) + + if (config.logger) { + this.api.use(createApiLogger(config.logger)) + } + } +} + +export type { components, paths } +export { ApiClient } diff --git a/packages/js-sdk/src/api/inflight.ts b/packages/js-sdk/src/api/inflight.ts new file mode 100644 index 0000000..3dc41ba --- /dev/null +++ b/packages/js-sdk/src/api/inflight.ts @@ -0,0 +1,78 @@ +/** + * Simple FIFO semaphore used to cap the number of in-flight requests sent + * through a fetch dispatcher. + */ +class Semaphore { + private active = 0 + private readonly queue: Array<() => void> = [] + + constructor(private readonly max: number) {} + + async acquire(signal?: AbortSignal): Promise<() => void> { + if (signal?.aborted) throw abortReason(signal) + if (this.active < this.max) { + this.active++ + return () => this.release() + } + + return new Promise<() => void>((resolve, reject) => { + const onAcquire = () => { + signal?.removeEventListener('abort', onAbort) + this.active++ + resolve(() => this.release()) + } + const onAbort = () => { + const i = this.queue.indexOf(onAcquire) + if (i >= 0) this.queue.splice(i, 1) + reject(abortReason(signal)) + } + this.queue.push(onAcquire) + signal?.addEventListener('abort', onAbort, { once: true }) + }) + } + + private release() { + this.active-- + const next = this.queue.shift() + if (next) next() + } +} + +function abortReason(signal: AbortSignal | undefined): unknown { + return signal?.reason ?? new DOMException('Aborted', 'AbortError') +} + +/** + * Wrap `fetcher` so at most `max` requests are in-flight at any time. + * Subsequent requests are FIFO-queued inside the SDK process and dispatched + * as earlier requests settle. + * + * NOTE: the slot is released as soon as `fetcher` resolves with the response + * headers, not when the response body is fully consumed. This means the + * effective concurrency can be higher than `max` while bodies are + * still streaming. + * + * TODO: release on body end (consume/cancel/error) so the + * SDK-level cap aligns with the dispatcher's connection accounting + */ +export function limitConcurrency( + fetcher: typeof fetch, + max: number +): typeof fetch { + if (!Number.isFinite(max) || max <= 0) { + return fetcher + } + + const sem = new Semaphore(max) + + return (async (input, init) => { + const signal = + init?.signal ?? (input instanceof Request ? input.signal : undefined) + const release = await sem.acquire(signal) + try { + return await fetcher(input, init) + } finally { + release() + } + }) as typeof fetch +} diff --git a/packages/js-sdk/src/api/metadata.ts b/packages/js-sdk/src/api/metadata.ts new file mode 100644 index 0000000..b863b1c --- /dev/null +++ b/packages/js-sdk/src/api/metadata.ts @@ -0,0 +1,85 @@ +import platform from 'platform' + +import { version } from '../../package.json' +import { runtime, runtimeVersion } from '../utils' + +export { version } + +export const defaultHeaders = { + browser: (typeof window !== 'undefined' && platform.name) || 'unknown', + lang: 'js', + lang_version: runtimeVersion, + package_version: version, + publisher: 'e2b', + sdk_runtime: runtime, + system: platform.os?.family || 'unknown', +} + +export function getEnvVar(name: string) { + if (runtime === 'deno') { + // @ts-ignore + return Deno.env.get(name) + } + + if (typeof process === 'undefined') { + return '' + } + + return process.env[name] +} + +/** + * Parse an env var as a base-10 integer, falling back to `defaultValue` when + * the env var is unset. Throws on non-integer input rather than silently + * falling back so misconfiguration is surfaced loudly. + */ +export function parseIntEnv(name: string, defaultValue: number): number { + const raw = getEnvVar(name) + if (!raw) return defaultValue + + const parsed = Number.parseInt(raw, 10) + if (!Number.isFinite(parsed)) { + throw new Error( + `Invalid ${name}=${JSON.stringify(raw)}: expected an integer.` + ) + } + + return parsed +} + +/** + * Parse an env var that must be a positive integer (>= 1). Throws on + * non-positive or non-integer input. + */ +export function parsePositiveIntEnv( + name: string, + defaultValue: number +): number { + const parsed = parseIntEnv(name, defaultValue) + if (parsed < 1) { + throw new Error(`Invalid ${name}=${parsed}: expected a positive integer.`) + } + + return parsed +} + +/** + * Parse an inflight-limit env var. Returns `0` to disable the cap (documented + * opt-out) or a positive integer to cap concurrency. Throws on non-integer or + * negative values so misconfiguration is surfaced loudly rather than silently + * removing the cap. A return value of `0` is recognized by + * {@link limitConcurrency} as "no cap". + */ +export function parseInflightLimitEnv( + name: string, + defaultValue: number +): number { + const parsed = parseIntEnv(name, defaultValue) + if (parsed < 0) { + throw new Error( + `Invalid ${name}=${parsed}: expected a non-negative integer ` + + '(use 0 to disable the cap).' + ) + } + return parsed +} diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts new file mode 100644 index 0000000..c6f357d --- /dev/null +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -0,0 +1,2892 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/sandboxes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List all running sandboxes */ + get: { + parameters: { + query?: { + /** @description Metadata query used to filter the sandboxes (e.g. "user=abc&app=prod"). Each key and values must be URL encoded. */ + metadata?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned all running sandboxes */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListedSandbox"][]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + /** @description Create a sandbox from the template */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NewSandbox"]; + }; + }; + responses: { + /** @description The sandbox was created successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Sandbox"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get a sandbox by id */ + get: { + parameters: { + query?: never; + header?: never; + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned the sandbox */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SandboxDetail"]; + }; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + /** @description Kill a sandbox */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The sandbox was killed successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxID}/connect": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Returns sandbox details. If the sandbox is paused, it will be resumed. TTL is only extended. */ + post: { + parameters: { + query?: never; + header?: never; + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConnectSandbox"]; + }; + }; + responses: { + /** @description The sandbox was already running */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Sandbox"]; + }; + }; + /** @description The sandbox was resumed successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Sandbox"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxID}/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * @deprecated + * @description Get sandbox logs. Use /v2/sandboxes/{sandboxID}/logs instead. + */ + get: { + parameters: { + query?: { + /** @description Maximum number of logs that should be returned */ + limit?: number; + /** @description Starting timestamp of the logs that should be returned in milliseconds */ + start?: number; + }; + header?: never; + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned the sandbox logs */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SandboxLogs"]; + }; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxID}/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get sandbox metrics */ + get: { + parameters: { + query?: { + end?: number; + /** @description Unix timestamp for the start of the interval, in seconds, for which the metrics */ + start?: number; + }; + header?: never; + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned the sandbox metrics */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SandboxMetric"][]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxID}/network": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** @description Update the network configuration for a running sandbox. Replaces the current egress rules with the provided configuration. Omitting field clears it. */ + put: { + parameters: { + query?: never; + header?: never; + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SandboxNetworkUpdateConfig"]; + }; + }; + responses: { + /** @description Successfully updated the sandbox network configuration */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 409: components["responses"]["409"]; + 500: components["responses"]["500"]; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxID}/pause": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Pause the sandbox */ + post: { + parameters: { + query?: never; + header?: never; + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SandboxPauseRequest"]; + }; + }; + responses: { + /** @description The sandbox was paused successfully and can be resumed */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 409: components["responses"]["409"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxID}/refreshes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Refresh the sandbox extending its time to live */ + post: { + parameters: { + query?: never; + header?: never; + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Duration for which the sandbox should be kept alive in seconds */ + duration?: number; + }; + }; + }; + responses: { + /** @description Successfully refreshed the sandbox */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxID}/resume": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * @deprecated + * @description Resume the sandbox + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ResumedSandbox"]; + }; + }; + responses: { + /** @description The sandbox was resumed successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Sandbox"]; + }; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 409: components["responses"]["409"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxID}/snapshots": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Create a persistent snapshot from the sandbox's current state. Snapshots can be used to create new sandboxes and persist beyond the original sandbox's lifetime. */ + post: { + parameters: { + query?: never; + header?: never; + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. */ + name?: string; + }; + }; + }; + responses: { + /** @description Snapshot created successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SnapshotInfo"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxID}/timeout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Set the timeout for the sandbox. The sandbox will expire x seconds from the time of the request. Calling this method multiple times overwrites the TTL, each time using the current timestamp as the starting point to measure the timeout duration. */ + post: { + parameters: { + query?: never; + header?: never; + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * Format: int32 + * @description Timeout in seconds from the current time after which the sandbox should expire + */ + timeout: number; + }; + }; + }; + responses: { + /** @description Successfully set the sandbox timeout */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List metrics for given sandboxes */ + get: { + parameters: { + query: { + /** @description Comma-separated list of sandbox IDs to get metrics for */ + sandbox_ids: string[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned all running sandboxes with metrics */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SandboxesWithMetrics"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/snapshots": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List all snapshots for the team */ + get: { + parameters: { + query?: { + /** @description Maximum number of items to return per page */ + limit?: components["parameters"]["paginationLimit"]; + /** @description Cursor to start the list from */ + nextToken?: components["parameters"]["paginationNextToken"]; + sandboxID?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned snapshots */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SnapshotInfo"][]; + }; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List all teams */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned all teams */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Team"][]; + }; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams/{teamID}/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get metrics for the team */ + get: { + parameters: { + query?: { + end?: number; + /** @description Unix timestamp for the start of the interval, in seconds, for which the metrics */ + start?: number; + }; + header?: never; + path: { + teamID: components["parameters"]["teamID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned the team metrics */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamMetric"][]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams/{teamID}/metrics/max": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get the maximum metrics for the team in the given interval */ + get: { + parameters: { + query: { + end?: number; + /** @description Metric to retrieve the maximum value for */ + metric: "concurrent_sandboxes" | "sandbox_start_rate"; + /** @description Unix timestamp for the start of the interval, in seconds, for which the metrics */ + start?: number; + }; + header?: never; + path: { + teamID: components["parameters"]["teamID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned the team metrics */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MaxTeamMetric"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/templates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List all templates */ + get: { + parameters: { + query?: { + teamID?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned all templates */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Template"][]; + }; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + /** + * @deprecated + * @description Create a new template + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateBuildRequest"]; + }; + }; + responses: { + /** @description The build was accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateLegacy"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/templates/{templateID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List all builds for a template */ + get: { + parameters: { + query?: { + /** @description Maximum number of items to return per page */ + limit?: components["parameters"]["paginationLimit"]; + /** @description Cursor to start the list from */ + nextToken?: components["parameters"]["paginationNextToken"]; + }; + header?: never; + path: { + templateID: components["parameters"]["templateID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned the template with its builds */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateWithBuilds"]; + }; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + /** + * @deprecated + * @description Rebuild an template + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + templateID: components["parameters"]["templateID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateBuildRequest"]; + }; + }; + responses: { + /** @description The build was accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateLegacy"]; + }; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + /** @description Delete a template */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + templateID: components["parameters"]["templateID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The template was deleted successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + options?: never; + head?: never; + /** + * @deprecated + * @description Update template + */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + templateID: components["parameters"]["templateID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateUpdateRequest"]; + }; + }; + responses: { + /** @description The template was updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + trace?: never; + }; + "/templates/{templateID}/builds/{buildID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * @deprecated + * @description Start the build + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + buildID: components["parameters"]["buildID"]; + templateID: components["parameters"]["templateID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The build has started */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/templates/{templateID}/builds/{buildID}/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get template build logs */ + get: { + parameters: { + query?: { + /** @description Starting timestamp of the logs that should be returned in milliseconds */ + cursor?: number; + direction?: components["schemas"]["LogsDirection"]; + level?: components["schemas"]["LogLevel"]; + /** @description Maximum number of logs that should be returned */ + limit?: number; + /** @description Source of the logs that should be returned from */ + source?: components["schemas"]["LogsSource"]; + }; + header?: never; + path: { + buildID: components["parameters"]["buildID"]; + templateID: components["parameters"]["templateID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned the template build logs */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateBuildLogsResponse"]; + }; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/templates/{templateID}/builds/{buildID}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get template build info */ + get: { + parameters: { + query?: { + level?: components["schemas"]["LogLevel"]; + /** @description Maximum number of logs that should be returned */ + limit?: number; + /** @description Index of the starting build log that should be returned with the template */ + logsOffset?: number; + }; + header?: never; + path: { + buildID: components["parameters"]["buildID"]; + templateID: components["parameters"]["templateID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned the template */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateBuildInfo"]; + }; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/templates/{templateID}/files/{hash}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get an upload link for a tar file containing build layer files */ + get: { + parameters: { + query?: never; + header?: never; + path: { + hash: string; + templateID: components["parameters"]["templateID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The upload link where to upload the tar file */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateBuildFileUpload"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/templates/{templateID}/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List all tags for a template */ + get: { + parameters: { + query?: never; + header?: never; + path: { + templateID: components["parameters"]["templateID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned the template tags */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateTag"][]; + }; + }; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/templates/aliases/{alias}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Check if template with given alias exists */ + get: { + parameters: { + query?: never; + header?: never; + path: { + alias: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully queried template by alias */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateAliasResponse"]; + }; + }; + 400: components["responses"]["400"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/templates/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Assign tag(s) to a template build */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AssignTemplateTagsRequest"]; + }; + }; + responses: { + /** @description Tag assigned successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AssignedTemplateTags"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + /** @description Delete multiple tags from templates */ + delete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DeleteTemplateTagsRequest"]; + }; + }; + responses: { + /** @description Tags deleted successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v2/sandboxes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List all sandboxes */ + get: { + parameters: { + query?: { + /** @description Maximum number of items to return per page */ + limit?: components["parameters"]["paginationLimit"]; + /** @description Metadata query used to filter the sandboxes (e.g. "user=abc&app=prod"). Each key and values must be URL encoded. */ + metadata?: string; + /** @description Cursor to start the list from */ + nextToken?: components["parameters"]["paginationNextToken"]; + /** @description Filter sandboxes by one or more states */ + state?: components["schemas"]["SandboxState"][]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned all running sandboxes */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListedSandbox"][]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v2/sandboxes/{sandboxID}/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get sandbox logs */ + get: { + parameters: { + query?: { + /** @description Starting timestamp of the logs that should be returned in milliseconds */ + cursor?: number; + /** @description Direction of the logs that should be returned */ + direction?: components["schemas"]["LogsDirection"]; + /** @description Minimum log level to return. Logs below this level are excluded */ + level?: components["schemas"]["LogLevel"]; + /** @description Maximum number of logs that should be returned */ + limit?: number; + /** @description Case-sensitive substring match on log message content */ + search?: string; + }; + header?: never; + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned the sandbox logs */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SandboxLogsV2Response"]; + }; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v2/templates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * @deprecated + * @description Create a new template + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateBuildRequestV2"]; + }; + }; + responses: { + /** @description The build was requested successfully */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateLegacy"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v2/templates/{templateID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** @description Update template */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + templateID: components["parameters"]["templateID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateUpdateRequest"]; + }; + }; + responses: { + /** @description The template was updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateUpdateResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + trace?: never; + }; + "/v2/templates/{templateID}/builds/{buildID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Start the build */ + post: { + parameters: { + query?: never; + header?: never; + path: { + buildID: components["parameters"]["buildID"]; + templateID: components["parameters"]["templateID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateBuildStartV2"]; + }; + }; + responses: { + /** @description The build has started */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/templates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Create a new template */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateBuildRequestV3"]; + }; + }; + responses: { + /** @description The build was requested successfully */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateRequestResponseV3"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/volumes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List all team volumes */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully listed all team volumes */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Volume"][]; + }; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + /** @description Create a new team volume */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NewVolume"]; + }; + }; + responses: { + /** @description Successfully created a new team volume */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VolumeAndToken"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/volumes/{volumeID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get team volume info */ + get: { + parameters: { + query?: never; + header?: never; + path: { + volumeID: components["parameters"]["volumeID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully retrieved a team volume */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VolumeAndToken"]; + }; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + /** @description Delete a team volume */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + volumeID: components["parameters"]["volumeID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully deleted a team volume */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + AdminBuildCancelResult: { + /** @description Number of builds successfully cancelled */ + cancelledCount: number; + /** @description Number of builds that failed to cancel */ + failedCount: number; + }; + AdminSandboxKillResult: { + /** @description Number of sandboxes that failed to kill */ + failedCount: number; + /** @description Number of sandboxes successfully killed */ + killedCount: number; + }; + AssignedTemplateTags: { + /** + * Format: uuid + * @description Identifier of the build associated with these tags + */ + buildID: string; + /** @description Assigned tags of the template */ + tags: string[]; + }; + AssignTemplateTagsRequest: { + /** @description Tags to assign to the template */ + tags: string[]; + /** @description Target template in "name:tag" format */ + target: string; + }; + AWSRegistry: { + /** @description AWS Access Key ID for ECR authentication */ + awsAccessKeyId: string; + /** @description AWS Region where the ECR registry is located */ + awsRegion: string; + /** @description AWS Secret Access Key for ECR authentication */ + awsSecretAccessKey: string; + /** + * @description Type of registry authentication (enum property replaced by openapi-typescript) + * @enum {string} + */ + type: "aws"; + }; + BuildLogEntry: { + level: components["schemas"]["LogLevel"]; + /** @description Log message content */ + message: string; + /** @description Step in the build process related to the log entry */ + step?: string; + /** + * Format: date-time + * @description Timestamp of the log entry + */ + timestamp: string; + }; + BuildStatusReason: { + /** + * @description Log entries related to the status reason + * @default [] + */ + logEntries?: components["schemas"]["BuildLogEntry"][]; + /** @description Message with the status reason, currently reporting only for error status */ + message: string; + /** @description Step that failed */ + step?: string; + }; + ConnectSandbox: { + /** + * Format: int32 + * @description Timeout in seconds from the current time after which the sandbox should expire + */ + timeout: number; + }; + /** + * Format: int32 + * @description CPU cores for the sandbox + */ + CPUCount: number; + CreatedAccessToken: { + /** + * Format: date-time + * @description Timestamp of access token creation + */ + createdAt: string; + /** + * Format: uuid + * @description Identifier of the access token + */ + id: string; + mask: components["schemas"]["IdentifierMaskingDetails"]; + /** @description Name of the access token */ + name: string; + /** @description The fully created access token */ + token: string; + }; + CreatedTeamAPIKey: { + /** + * Format: date-time + * @description Timestamp of API key creation + */ + createdAt: string; + createdBy?: components["schemas"]["TeamUser"] | null; + /** + * Format: uuid + * @description Identifier of the API key + */ + id: string; + /** @description Raw value of the API key */ + key: string; + /** + * Format: date-time + * @description Last time this API key was used + */ + lastUsed?: string | null; + mask: components["schemas"]["IdentifierMaskingDetails"]; + /** @description Name of the API key */ + name: string; + }; + DeleteTemplateTagsRequest: { + /** @description Name of the template */ + name: string; + /** @description Tags to delete */ + tags: string[]; + }; + DiskMetrics: { + /** @description Device name */ + device: string; + /** @description Filesystem type (e.g., ext4, xfs) */ + filesystemType: string; + /** @description Mount point of the disk */ + mountPoint: string; + /** + * Format: uint64 + * @description Total space in bytes + */ + totalBytes: number; + /** + * Format: uint64 + * @description Used space in bytes + */ + usedBytes: number; + }; + /** + * Format: int32 + * @description Disk size for the sandbox in MiB + */ + DiskSizeMB: number; + /** @description Version of the envd running in the sandbox */ + EnvdVersion: string; + EnvVars: { + [key: string]: string; + }; + Error: { + /** + * Format: int32 + * @description Error code + */ + code: number; + /** @description Error */ + message: string; + }; + FromImageRegistry: components["schemas"]["AWSRegistry"] | components["schemas"]["GCPRegistry"] | components["schemas"]["GeneralRegistry"]; + GCPRegistry: { + /** @description Service Account JSON for GCP authentication */ + serviceAccountJson: string; + /** + * @description Type of registry authentication (enum property replaced by openapi-typescript) + * @enum {string} + */ + type: "gcp"; + }; + GeneralRegistry: { + /** @description Password to use for the registry */ + password: string; + /** + * @description Type of registry authentication (enum property replaced by openapi-typescript) + * @enum {string} + */ + type: "registry"; + /** @description Username to use for the registry */ + username: string; + }; + IdentifierMaskingDetails: { + /** @description Prefix used in masked version of the token or key */ + maskedValuePrefix: string; + /** @description Suffix used in masked version of the token or key */ + maskedValueSuffix: string; + /** @description Prefix that identifies the token or key type */ + prefix: string; + /** @description Length of the token or key */ + valueLength: number; + }; + ListedSandbox: { + /** @description Alias of the template */ + alias?: string; + /** + * @deprecated + * @description Identifier of the client + */ + clientID: string; + cpuCount: components["schemas"]["CPUCount"]; + diskSizeMB: components["schemas"]["DiskSizeMB"]; + /** + * Format: date-time + * @description Time when the sandbox will expire + */ + endAt: string; + envdVersion: components["schemas"]["EnvdVersion"]; + memoryMB: components["schemas"]["MemoryMB"]; + metadata?: components["schemas"]["SandboxMetadata"]; + /** @description Identifier of the sandbox */ + sandboxID: string; + /** + * Format: date-time + * @description Time when the sandbox was started + */ + startedAt: string; + state: components["schemas"]["SandboxState"]; + /** @description Identifier of the template from which is the sandbox created */ + templateID: string; + volumeMounts?: components["schemas"]["SandboxVolumeMount"][]; + }; + /** + * @description State of the sandbox + * @enum {string} + */ + LogLevel: "debug" | "info" | "warn" | "error"; + /** + * @description Direction of the logs that should be returned + * @enum {string} + */ + LogsDirection: "forward" | "backward"; + /** + * @description Source of the logs that should be returned + * @enum {string} + */ + LogsSource: "temporary" | "persistent"; + MachineInfo: { + /** @description CPU architecture of the node */ + cpuArchitecture: string; + /** @description CPU family of the node */ + cpuFamily: string; + /** @description CPU model of the node */ + cpuModel: string; + /** @description CPU model name of the node */ + cpuModelName: string; + }; + /** @description Team metric with timestamp */ + MaxTeamMetric: { + /** + * Format: date-time + * @deprecated + * @description Timestamp of the metric entry + */ + timestamp: string; + /** + * Format: int64 + * @description Timestamp of the metric entry in Unix time (seconds since epoch) + */ + timestampUnix: number; + /** @description The maximum value of the requested metric in the given interval */ + value: number; + }; + /** @description MCP configuration for the sandbox */ + Mcp: { + [key: string]: unknown; + } | null; + /** + * Format: int32 + * @description Memory for the sandbox in MiB + */ + MemoryMB: number; + NewAccessToken: { + /** @description Name of the access token */ + name: string; + }; + NewSandbox: { + /** @description Allow sandbox to access the internet. When set to false, it behaves the same as specifying denyOut to 0.0.0.0/0 in the network config. */ + allow_internet_access?: boolean; + /** + * @description Automatically pauses the sandbox after the timeout + * @default false + */ + autoPause?: boolean; + /** + * @description Controls the snapshot kind taken when the sandbox auto-pauses on timeout (only relevant when autoPause is true). When false, the auto-pause drops the in-memory state and persists only the filesystem (a filesystem-only snapshot); resuming it cold-boots (reboots) the sandbox from disk. Such a snapshot cannot be auto-resumed by traffic and must be resumed explicitly, so it cannot be combined with autoResume. Defaults to true (full memory snapshot). + * @default true + */ + autoPauseMemory?: boolean; + autoResume?: components["schemas"]["SandboxAutoResumeConfig"]; + envVars?: components["schemas"]["EnvVars"]; + mcp?: components["schemas"]["Mcp"]; + metadata?: components["schemas"]["SandboxMetadata"]; + network?: components["schemas"]["SandboxNetworkConfig"]; + /** @description Secure all system communication with sandbox */ + secure?: boolean; + /** @description Identifier of the required template */ + templateID: string; + /** + * Format: int32 + * @description Time to live for the sandbox in seconds. + * @default 15 + */ + timeout?: number; + volumeMounts?: components["schemas"]["SandboxVolumeMount"][]; + }; + NewTeamAPIKey: { + /** @description Name of the API key */ + name: string; + }; + NewVolume: { + /** @description Name of the volume */ + name: string; + }; + Node: { + /** @description Identifier of the cluster */ + clusterID: string; + /** @description Commit of the orchestrator */ + commit: string; + /** + * Format: uint64 + * @description Number of sandbox create fails + */ + createFails: number; + /** + * Format: uint64 + * @description Number of sandbox create successes + */ + createSuccesses: number; + /** @description Identifier of the node */ + id: string; + machineInfo: components["schemas"]["MachineInfo"]; + metrics: components["schemas"]["NodeMetrics"]; + /** + * Format: uint32 + * @description Number of sandboxes running on the node + */ + sandboxCount: number; + /** + * Format: int + * @description Number of starting Sandboxes + */ + sandboxStartingCount: number; + /** @description Service instance identifier of the node */ + serviceInstanceID: string; + status: components["schemas"]["NodeStatus"]; + /** @description Version of the orchestrator */ + version: string; + }; + NodeDetail: { + /** @description List of cached builds id on the node */ + cachedBuilds: string[]; + /** @description Identifier of the cluster */ + clusterID: string; + /** @description Commit of the orchestrator */ + commit: string; + /** + * Format: uint64 + * @description Number of sandbox create fails + */ + createFails: number; + /** + * Format: uint64 + * @description Number of sandbox create successes + */ + createSuccesses: number; + /** @description Identifier of the node */ + id: string; + machineInfo: components["schemas"]["MachineInfo"]; + metrics: components["schemas"]["NodeMetrics"]; + /** + * Format: uint32 + * @description Number of sandboxes running on the node + */ + sandboxCount: number; + /** @description Service instance identifier of the node */ + serviceInstanceID: string; + status: components["schemas"]["NodeStatus"]; + /** @description Version of the orchestrator */ + version: string; + }; + /** @description Node metrics */ + NodeMetrics: { + /** + * Format: uint32 + * @description Number of allocated CPU cores + */ + allocatedCPU: number; + /** + * Format: uint64 + * @description Amount of allocated memory in bytes + */ + allocatedMemoryBytes: number; + /** + * Format: uint32 + * @description Total number of CPU cores on the node + */ + cpuCount: number; + /** + * Format: uint32 + * @description Node CPU usage percentage + */ + cpuPercent: number; + /** @description Detailed metrics for each disk/mount point */ + disks: components["schemas"]["DiskMetrics"][]; + /** + * Format: uint64 + * @description Total node memory in bytes + */ + memoryTotalBytes: number; + /** + * Format: uint64 + * @description Node memory used in bytes + */ + memoryUsedBytes: number; + }; + /** + * @description Status of the node. + * - draining: the node is bound to be shut down. It will not accept new sandboxes and will stop once all existing sandboxes are done. + * - standby: the node is not actively used, but it can return to ready and continue serving traffic. + * + * @enum {string} + */ + NodeStatus: "ready" | "draining" | "connecting" | "unhealthy" | "standby"; + NodeStatusChange: { + /** + * Format: uuid + * @description Identifier of the cluster + */ + clusterID?: string; + status: components["schemas"]["NodeStatus"]; + }; + ResumedSandbox: { + /** + * @deprecated + * @description Automatically pauses the sandbox after the timeout + */ + autoPause?: boolean; + /** + * Format: int32 + * @description Time to live for the sandbox in seconds. + * @default 15 + */ + timeout?: number; + }; + Sandbox: { + /** @description Alias of the template */ + alias?: string; + /** + * @deprecated + * @description Identifier of the client + */ + clientID: string; + /** @description Base domain where the sandbox traffic is accessible */ + domain?: string | null; + /** @description Access token used for envd communication */ + envdAccessToken?: string; + envdVersion: components["schemas"]["EnvdVersion"]; + /** @description Identifier of the sandbox */ + sandboxID: string; + /** @description Identifier of the template from which is the sandbox created */ + templateID: string; + /** @description Token required for accessing sandbox via proxy. */ + trafficAccessToken?: string | null; + }; + /** @description Auto-resume configuration for paused sandboxes. */ + SandboxAutoResumeConfig: { + enabled: components["schemas"]["SandboxAutoResumeEnabled"]; + }; + /** + * @description Auto-resume enabled flag for paused sandboxes. Default false. + * @default false + */ + SandboxAutoResumeEnabled: boolean; + SandboxDetail: { + /** @description Alias of the template */ + alias?: string; + /** @description Whether internet access was explicitly enabled or disabled for the sandbox. Null means it was not explicitly set. */ + allowInternetAccess?: boolean | null; + /** + * @deprecated + * @description Identifier of the client + */ + clientID: string; + cpuCount: components["schemas"]["CPUCount"]; + diskSizeMB: components["schemas"]["DiskSizeMB"]; + /** @description Base domain where the sandbox traffic is accessible */ + domain?: string | null; + /** + * Format: date-time + * @description Time when the sandbox will expire + */ + endAt: string; + /** @description Access token used for envd communication */ + envdAccessToken?: string; + envdVersion: components["schemas"]["EnvdVersion"]; + lifecycle?: components["schemas"]["SandboxLifecycle"]; + memoryMB: components["schemas"]["MemoryMB"]; + metadata?: components["schemas"]["SandboxMetadata"]; + network?: components["schemas"]["SandboxNetworkConfig"]; + /** @description Identifier of the sandbox */ + sandboxID: string; + /** + * Format: date-time + * @description Time when the sandbox was started + */ + startedAt: string; + state: components["schemas"]["SandboxState"]; + /** @description Identifier of the template from which is the sandbox created */ + templateID: string; + volumeMounts?: components["schemas"]["SandboxVolumeMount"][]; + }; + SandboxesWithMetrics: { + sandboxes: { + [key: string]: components["schemas"]["SandboxMetric"]; + }; + }; + /** @description Sandbox lifecycle policy returned by sandbox info. */ + SandboxLifecycle: { + /** @description Whether the sandbox can auto-resume. */ + autoResume: boolean; + onTimeout: components["schemas"]["SandboxOnTimeout"]; + }; + /** @description Log entry with timestamp and line */ + SandboxLog: { + /** @description Log line content */ + line: string; + /** + * Format: date-time + * @description Timestamp of the log entry + */ + timestamp: string; + }; + SandboxLogEntry: { + fields: { + [key: string]: string; + }; + level: components["schemas"]["LogLevel"]; + /** @description Log message content */ + message: string; + /** + * Format: date-time + * @description Timestamp of the log entry + */ + timestamp: string; + }; + SandboxLogs: { + /** @description Structured logs of the sandbox */ + logEntries: components["schemas"]["SandboxLogEntry"][]; + /** @description Logs of the sandbox */ + logs: components["schemas"]["SandboxLog"][]; + }; + SandboxLogsV2Response: { + /** + * @description Sandbox logs structured + * @default [] + */ + logs: components["schemas"]["SandboxLogEntry"][]; + }; + SandboxMetadata: { + [key: string]: string; + }; + /** @description Metric entry with timestamp and line */ + SandboxMetric: { + /** + * Format: int32 + * @description Number of CPU cores + */ + cpuCount: number; + /** + * Format: float + * @description CPU usage percentage + */ + cpuUsedPct: number; + /** + * Format: int64 + * @description Total disk space in bytes + */ + diskTotal: number; + /** + * Format: int64 + * @description Disk used in bytes + */ + diskUsed: number; + /** + * Format: int64 + * @description Cached memory (page cache) in bytes + */ + memCache: number; + /** + * Format: int64 + * @description Total memory in bytes + */ + memTotal: number; + /** + * Format: int64 + * @description Memory used in bytes + */ + memUsed: number; + /** + * Format: date-time + * @deprecated + * @description Timestamp of the metric entry + */ + timestamp: string; + /** + * Format: int64 + * @description Timestamp of the metric entry in Unix time (seconds since epoch) + */ + timestampUnix: number; + }; + SandboxNetworkConfig: { + /** @description List of allowed destinations for egress traffic. Each entry can be a CIDR block (e.g. "8.8.8.8/32"), a bare IP address (e.g. "8.8.8.8"), or a domain name (e.g. "example.com", "*.example.com"). Allowed entries always take precedence over denied entries. */ + allowOut?: string[]; + /** + * @description Specify if the sandbox URLs should be accessible only with authentication. + * @default true + */ + allowPublicTraffic?: boolean; + /** @description List of denied CIDR blocks or IP addresses for egress traffic. Domain names are not supported for deny rules. */ + denyOut?: string[]; + /** @description Specify host mask which will be used for all sandbox requests */ + maskRequestHost?: string; + /** @description Per-domain transform rules applied to matching egress HTTP/HTTPS requests. Keys are domains (e.g. "api.example.com", "example.com"). A domain listed here is not automatically allowed - use allowOut to permit the traffic. + * */ + rules?: { + [key: string]: components["schemas"]["SandboxNetworkRule"][]; + }; + }; + /** @description Transform rule applied to egress requests matching a domain pattern. */ + SandboxNetworkRule: { + transform?: components["schemas"]["SandboxNetworkTransform"]; + }; + /** @description Transformations applied to matching egress requests before forwarding. */ + SandboxNetworkTransform: { + /** @description HTTP headers to inject or override in matching requests. An existing header with the same name is replaced. Values are plain strings; secret resolution happens client-side before sending to the API. + * */ + headers?: { + [key: string]: string; + }; + }; + /** @description Network configuration update for a running sandbox. Replaces the current egress rules with the provided configuration. Omitting a field clears it. */ + SandboxNetworkUpdateConfig: { + /** @description Allow sandbox to access the internet. When set to false, it behaves the same as specifying denyOut to 0.0.0.0/0 in the network config. */ + allow_internet_access?: boolean; + /** @description List of allowed destinations for egress traffic. Each entry can be a CIDR block (e.g. "8.8.8.8/32"), a bare IP address (e.g. "8.8.8.8"), or a domain name (e.g. "example.com", "*.example.com"). Allowed entries always take precedence over denied entries. */ + allowOut?: string[]; + /** @description List of denied CIDR blocks or IP addresses for egress traffic. Domain names are not supported for deny rules. */ + denyOut?: string[]; + /** @description Per-domain transform rules. Replaces all existing rules when provided. */ + rules?: { + [key: string]: components["schemas"]["SandboxNetworkRule"][]; + }; + }; + /** + * @description Action taken when the sandbox times out. + * @enum {string} + */ + SandboxOnTimeout: "kill" | "pause"; + SandboxPauseRequest: { + /** + * @description Whether to capture a full memory snapshot. When false, only the filesystem is persisted and resuming the sandbox cold-boots (reboots) it from disk, losing in-memory state, running processes, and open connections. Resume it with an explicit request (connect or resume); auto-resume, which can be triggered by arbitrary traffic, refuses such a sandbox. Defaults to true. + * @default true + */ + memory?: boolean; + }; + /** + * @description State of the sandbox + * @enum {string} + */ + SandboxState: "running" | "paused"; + SandboxVolumeMount: { + /** @description Name of the volume */ + name: string; + /** @description Path of the volume */ + path: string; + }; + SnapshotInfo: { + /** @description Full names of the snapshot template including team namespace and tag (e.g. team-slug/my-snapshot:v2) */ + names: string[]; + /** @description Identifier of the snapshot template including the tag. Uses namespace/alias when a name was provided (e.g. team-slug/my-snapshot:default), otherwise falls back to the raw template ID (e.g. abc123:default). */ + snapshotID: string; + }; + Team: { + /** @description API key for the team */ + apiKey: string; + /** @description Whether the team is the default team */ + isDefault: boolean; + /** @description Name of the team */ + name: string; + /** @description Identifier of the team */ + teamID: string; + }; + TeamAPIKey: { + /** + * Format: date-time + * @description Timestamp of API key creation + */ + createdAt: string; + createdBy?: components["schemas"]["TeamUser"] | null; + /** + * Format: uuid + * @description Identifier of the API key + */ + id: string; + /** + * Format: date-time + * @description Last time this API key was used + */ + lastUsed?: string | null; + mask: components["schemas"]["IdentifierMaskingDetails"]; + /** @description Name of the API key */ + name: string; + }; + /** @description Team metric with timestamp */ + TeamMetric: { + /** + * Format: int32 + * @description The number of concurrent sandboxes for the team + */ + concurrentSandboxes: number; + /** + * Format: float + * @description Number of sandboxes started per second + */ + sandboxStartRate: number; + /** + * Format: date-time + * @deprecated + * @description Timestamp of the metric entry + */ + timestamp: string; + /** + * Format: int64 + * @description Timestamp of the metric entry in Unix time (seconds since epoch) + */ + timestampUnix: number; + }; + TeamUser: { + /** + * @deprecated + * @description Email of the user + * @default null + */ + email: string | null; + /** + * Format: uuid + * @description Identifier of the user + */ + id: string; + }; + Template: { + /** + * @deprecated + * @description Aliases of the template + */ + aliases: string[]; + /** + * Format: int32 + * @description Number of times the template was built + */ + buildCount: number; + /** @description Identifier of the last successful build for given template */ + buildID: string; + buildStatus: components["schemas"]["TemplateBuildStatus"]; + cpuCount: components["schemas"]["CPUCount"]; + /** + * Format: date-time + * @description Time when the template was created + */ + createdAt: string; + createdBy: components["schemas"]["TeamUser"] | null; + diskSizeMB: components["schemas"]["DiskSizeMB"]; + envdVersion: components["schemas"]["EnvdVersion"]; + /** + * Format: date-time + * @description Time when the template was last used + */ + lastSpawnedAt: string | null; + memoryMB: components["schemas"]["MemoryMB"]; + /** @description Names of the template (namespace/alias format when namespaced) */ + names: string[]; + /** @description Whether the template is public or only accessible by the team */ + public: boolean; + /** + * Format: int64 + * @description Number of times the template was used + */ + spawnCount: number; + /** @description Identifier of the template */ + templateID: string; + /** + * Format: date-time + * @description Time when the template was last updated + */ + updatedAt: string; + }; + TemplateAliasResponse: { + /** @description Whether the template is public or only accessible by the team */ + public: boolean; + /** @description Identifier of the template */ + templateID: string; + }; + TemplateBuild: { + /** + * Format: uuid + * @description Identifier of the build + */ + buildID: string; + cpuCount: components["schemas"]["CPUCount"]; + /** + * Format: date-time + * @description Time when the build was created + */ + createdAt: string; + diskSizeMB?: components["schemas"]["DiskSizeMB"]; + envdVersion?: components["schemas"]["EnvdVersion"]; + /** + * Format: date-time + * @description Time when the build was finished + */ + finishedAt?: string; + memoryMB: components["schemas"]["MemoryMB"]; + status: components["schemas"]["TemplateBuildStatus"]; + /** + * Format: date-time + * @description Time when the build was last updated + */ + updatedAt: string; + }; + TemplateBuildFileUpload: { + /** @description Whether the file is already present in the cache */ + present: boolean; + /** @description Url where the file should be uploaded to */ + url?: string; + }; + TemplateBuildInfo: { + /** @description Identifier of the build */ + buildID: string; + /** + * @description Build logs structured + * @default [] + */ + logEntries: components["schemas"]["BuildLogEntry"][]; + /** + * @description Build logs + * @default [] + */ + logs: string[]; + reason?: components["schemas"]["BuildStatusReason"]; + status: components["schemas"]["TemplateBuildStatus"]; + /** @description Identifier of the template */ + templateID: string; + }; + TemplateBuildLogsResponse: { + /** + * @description Build logs structured + * @default [] + */ + logs: components["schemas"]["BuildLogEntry"][]; + }; + TemplateBuildRequest: { + /** @description Alias of the template */ + alias?: string; + cpuCount?: components["schemas"]["CPUCount"]; + /** @description Dockerfile for the template */ + dockerfile: string; + memoryMB?: components["schemas"]["MemoryMB"]; + /** @description Ready check command to execute in the template after the build */ + readyCmd?: string; + /** @description Start command to execute in the template after the build */ + startCmd?: string; + /** @description Identifier of the team */ + teamID?: string; + }; + TemplateBuildRequestV2: { + /** @description Alias of the template */ + alias: string; + cpuCount?: components["schemas"]["CPUCount"]; + memoryMB?: components["schemas"]["MemoryMB"]; + /** + * @deprecated + * @description Identifier of the team + */ + teamID?: string; + }; + TemplateBuildRequestV3: { + /** + * @deprecated + * @description Alias of the template. Deprecated, use name instead. + */ + alias?: string; + cpuCount?: components["schemas"]["CPUCount"]; + memoryMB?: components["schemas"]["MemoryMB"]; + /** @description Name of the template. Can include a tag with colon separator (e.g. "my-template" or "my-template:v1"). If tag is included, it will be treated as if the tag was provided in the tags array. */ + name?: string; + /** @description Tags to assign to the template build */ + tags?: string[]; + /** + * @deprecated + * @description Identifier of the team + */ + teamID?: string; + }; + TemplateBuildStartV2: { + /** + * @description Whether the whole build should be forced to run regardless of the cache + * @default false + */ + force?: boolean; + /** @description Image to use as a base for the template build */ + fromImage?: string; + fromImageRegistry?: components["schemas"]["FromImageRegistry"]; + /** @description Template to use as a base for the template build */ + fromTemplate?: string; + /** @description Ready check command to execute in the template after the build */ + readyCmd?: string; + /** @description Start command to execute in the template after the build */ + startCmd?: string; + /** + * @description List of steps to execute in the template build + * @default [] + */ + steps?: components["schemas"]["TemplateStep"][]; + }; + /** + * @description Status of the template build + * @enum {string} + */ + TemplateBuildStatus: "building" | "waiting" | "ready" | "error"; + TemplateLegacy: { + /** @description Aliases of the template */ + aliases: string[]; + /** + * Format: int32 + * @description Number of times the template was built + */ + buildCount: number; + /** @description Identifier of the last successful build for given template */ + buildID: string; + cpuCount: components["schemas"]["CPUCount"]; + /** + * Format: date-time + * @description Time when the template was created + */ + createdAt: string; + createdBy: components["schemas"]["TeamUser"] | null; + diskSizeMB: components["schemas"]["DiskSizeMB"]; + envdVersion: components["schemas"]["EnvdVersion"]; + /** + * Format: date-time + * @description Time when the template was last used + */ + lastSpawnedAt: string | null; + memoryMB: components["schemas"]["MemoryMB"]; + /** @description Whether the template is public or only accessible by the team */ + public: boolean; + /** + * Format: int64 + * @description Number of times the template was used + */ + spawnCount: number; + /** @description Identifier of the template */ + templateID: string; + /** + * Format: date-time + * @description Time when the template was last updated + */ + updatedAt: string; + }; + TemplateRequestResponseV3: { + /** + * @deprecated + * @description Aliases of the template + */ + aliases: string[]; + /** @description Identifier of the last successful build for given template */ + buildID: string; + /** @description Names of the template */ + names: string[]; + /** @description Whether the template is public or only accessible by the team */ + public: boolean; + /** @description Tags assigned to the template build */ + tags: string[]; + /** @description Identifier of the template */ + templateID: string; + }; + /** @description Step in the template build process */ + TemplateStep: { + /** + * @description Arguments for the step + * @default [] + */ + args?: string[]; + /** @description Hash of the files used in the step */ + filesHash?: string; + /** + * @description Whether the step should be forced to run regardless of the cache + * @default false + */ + force?: boolean; + /** @description Type of the step */ + type: string; + }; + TemplateTag: { + /** + * Format: uuid + * @description Identifier of the build associated with this tag + */ + buildID: string; + /** + * Format: date-time + * @description Time when the tag was assigned + */ + createdAt: string; + /** @description The tag name */ + tag: string; + }; + TemplateUpdateRequest: { + /** @description Whether the template is public or only accessible by the team */ + public?: boolean; + }; + TemplateUpdateResponse: { + /** @description Names of the template (namespace/alias format when namespaced) */ + names: string[]; + }; + TemplateWithBuilds: { + /** + * @deprecated + * @description Aliases of the template + */ + aliases: string[]; + /** @description List of builds for the template */ + builds: components["schemas"]["TemplateBuild"][]; + /** + * Format: date-time + * @description Time when the template was created + */ + createdAt: string; + /** + * Format: date-time + * @description Time when the template was last used + */ + lastSpawnedAt: string | null; + /** @description Names of the template (namespace/alias format when namespaced) */ + names: string[]; + /** @description Whether the template is public or only accessible by the team */ + public: boolean; + /** + * Format: int64 + * @description Number of times the template was used + */ + spawnCount: number; + /** @description Identifier of the template */ + templateID: string; + /** + * Format: date-time + * @description Time when the template was last updated + */ + updatedAt: string; + }; + UpdateTeamAPIKey: { + /** @description New name for the API key */ + name: string; + }; + Volume: { + /** @description Name of the volume */ + name: string; + /** @description ID of the volume */ + volumeID: string; + }; + VolumeAndToken: { + /** @description Name of the volume */ + name: string; + /** @description Auth token to use for interacting with volume content */ + token: string; + /** @description ID of the volume */ + volumeID: string; + }; + VolumeToken: { + token: string; + }; + }; + responses: { + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Authentication error */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + parameters: { + accessTokenID: string; + apiKeyID: string; + buildID: string; + nodeID: string; + /** @description Maximum number of items to return per page */ + paginationLimit: number; + /** @description Cursor to start the list from */ + paginationNextToken: string; + sandboxID: string; + snapshotID: string; + tag: string; + teamID: string; + templateID: string; + volumeID: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts new file mode 100644 index 0000000..7b03971 --- /dev/null +++ b/packages/js-sdk/src/connectionConfig.ts @@ -0,0 +1,498 @@ +import { Logger } from './logs' +import { getEnvVar, version } from './api/metadata' +import { runtime } from './utils' + +// Remove once all deployments support sandbox subdomains +const supportedDomains = ['e2b.app', 'e2b.dev', 'e2b.pro', 'e2b-staging.dev'] + +export const REQUEST_TIMEOUT_MS = 60_000 // 60 seconds +export const DEFAULT_SANDBOX_TIMEOUT_MS = 300_000 // 300 seconds +export const KEEPALIVE_PING_INTERVAL_SEC = 50 // 50 seconds + +export const KEEPALIVE_PING_HEADER = 'Keepalive-Ping-Interval' + +/** + * Connection options for requests to the API. + */ +export interface ConnectionOpts { + /** + * E2B API key to use for authentication. + * + * @default E2B_API_KEY // environment variable + */ + apiKey?: string + /** + * Whether to validate the format of the E2B API key on the client side. + * Disable this when your deployment issues API keys that don't match the + * default `e2b_` format. + * + * @default E2B_VALIDATE_API_KEY // environment variable or `true` + */ + validateApiKey?: boolean + /** + * E2B access token to use for authentication. + * + * @deprecated Pass the token through `apiHeaders` instead, e.g. + * `apiHeaders: { Authorization: \`Bearer ${token}\` }`. + * + * @default E2B_ACCESS_TOKEN // environment variable + */ + accessToken?: string + /** + * Domain to use for the API. + * + * @default E2B_DOMAIN // environment variable or `e2b.app` + */ + domain?: string + /** + * API Url to use for the API. + * @internal + * @default E2B_API_URL // environment variable or `https://api.${domain}` + */ + apiUrl?: string + /** + * Sandbox Url to use for the API. + * @internal + * @default E2B_SANDBOX_URL // environment variable, `https://sandbox.${domain}` + */ + sandboxUrl?: string + /** + * If true the SDK starts in the debug mode and connects to the local envd API server. + * @internal + * @default E2B_DEBUG // environment variable or `false` + */ + debug?: boolean + /** + * Timeout for requests to the API in **milliseconds**. + * + * @default 60_000 // 60 seconds + */ + requestTimeoutMs?: number + /** + * Logger to use for logging messages. It can accept any object that implements `Logger` interface—for example, {@link console}. + */ + logger?: Logger + + /** + * Additional headers to send with the request. + * + * @deprecated Use `apiHeaders` instead. + */ + headers?: Record + + /** + * Proxy URL to use for requests. In case of a sandbox it applies to all + * requests made to the returned sandbox. + * + * @example 'http://user:pass@127.0.0.1:8080' + */ + proxy?: string + + /** + * Additional headers to send with E2B API requests. + */ + apiHeaders?: Record + + /** + * An optional `AbortSignal` that can be used to cancel the in-flight request. + * When the signal is aborted, the underlying `fetch` is aborted and the + * returned promise rejects with an `AbortError`. + */ + signal?: AbortSignal +} + +/** + * Options accepted by `ConnectionConfig`. + * + * @deprecated Use `ConnectionOpts` instead. + */ +export type ConnectionConfigOpts = ConnectionOpts + +/** + * Build an `AbortSignal` that combines an optional request-timeout signal + * (via `AbortSignal.timeout`) with an optional user-provided signal. + * + * Returns `undefined` when neither input would produce a signal. + * + * @internal + */ +export function buildRequestSignal( + requestTimeoutMs: number | undefined, + userSignal: AbortSignal | undefined +): AbortSignal | undefined { + // `0` (and `undefined`) disable the request timeout. + const timeoutSignal = requestTimeoutMs + ? AbortSignal.timeout(requestTimeoutMs) + : undefined + + if (timeoutSignal && userSignal) { + return AbortSignal.any([timeoutSignal, userSignal]) + } + + return timeoutSignal ?? userSignal +} + +/** + * Set up an internal `AbortController` for a streaming request. + * + * Until `clearStartTimeout` is called, the controller aborts when either + * - the optional user signal aborts, or + * - the optional request timeout elapses (used to bound the initial + * handshake; long-lived streams should call `clearStartTimeout` once + * the handshake succeeds). + * + * The user-signal listener stays attached for the full stream lifetime + * so the caller can cancel a long-running stream by aborting the signal. + * + * `cleanup` is idempotent and detaches the listener, clears the handshake + * timer (if still pending), and aborts the controller. Call it when the + * stream finishes or when startup fails. + * + * @internal + */ +export function setupRequestController( + requestTimeoutMs: number | undefined, + userSignal: AbortSignal | undefined +): { + controller: AbortController + clearStartTimeout: () => void + cleanup: () => void +} { + const controller = new AbortController() + + const onUserAbort = () => controller.abort(userSignal?.reason) + if (userSignal) { + if (userSignal.aborted) { + controller.abort(userSignal.reason) + } else { + userSignal.addEventListener('abort', onUserAbort, { once: true }) + } + } + + let reqTimeout: ReturnType | undefined = requestTimeoutMs + ? setTimeout( + () => + controller.abort( + new DOMException( + `Request handshake timed out after ${requestTimeoutMs}ms`, + 'TimeoutError' + ) + ), + requestTimeoutMs + ) + : undefined + + const clearStartTimeout = () => { + if (reqTimeout) { + clearTimeout(reqTimeout) + reqTimeout = undefined + } + } + + let cleaned = false + const cleanup = () => { + if (cleaned) return + cleaned = true + userSignal?.removeEventListener('abort', onUserAbort) + clearStartTimeout() + controller.abort() + } + + return { controller, clearStartTimeout, cleanup } +} + +/** + * Create a resettable idle-timeout that aborts `controller` when no progress is + * made within `idleTimeoutMs`. `arm` (re)starts the timer; call it on each + * chunk. `clear` stops it. `0`/`undefined` disables it (both are no-ops). + * + * @internal + */ +function createIdleAbort( + controller: AbortController, + idleTimeoutMs: number | undefined, + label: string +): { arm: () => void; clear: () => void } { + let timer: ReturnType | undefined + const clear = () => { + if (timer) { + clearTimeout(timer) + timer = undefined + } + } + const arm = () => { + if (!idleTimeoutMs) return + clear() + timer = setTimeout( + () => + controller.abort( + new DOMException( + `${label} idle for ${idleTimeoutMs}ms`, + 'TimeoutError' + ) + ), + idleTimeoutMs + ) + } + return { arm, clear } +} + +/** + * Wrap a streaming response body so its pooled connection is released when the + * stream is fully read, cancelled, errors, or stays idle for too long. + * + * Clears the handshake timeout from {@link setupRequestController} (so + * consuming the body isn't killed by it) and replaces it with an idle-read + * timeout that bounds only the wire: it's armed while waiting on a network + * read and cleared the moment a chunk arrives, so a slow or paused consumer + * never trips it (only a server that stops sending mid-stream does). On expiry + * it aborts `controller`, tearing down the fetch and releasing the connection. + * Pass `0`/`undefined` to disable. Call once the handshake has succeeded. + * + * @internal + */ +export function wrapStreamWithConnectionCleanup( + body: ReadableStream | null, + { + clearStartTimeout, + cleanup, + controller, + idleTimeoutMs, + }: { + clearStartTimeout: () => void + cleanup: () => void + controller: AbortController + idleTimeoutMs?: number + } +): ReadableStream { + clearStartTimeout() + + if (!body) { + cleanup() + return new Blob([]).stream() + } + + const reader = body.getReader() + const idle = createIdleAbort(controller, idleTimeoutMs, 'Stream') + + // Idempotent: safe to call from multiple stream callbacks. + const release = () => { + idle.clear() + cleanup() + } + + return new ReadableStream({ + async pull(streamController) { + // Bound only the wire: arm before reading from the network and clear the + // moment a chunk (or EOF) arrives, so a slow or paused consumer never + // counts against the idle timeout. A consumer that holds the stream but + // stops reading is never pulled here, so nothing arms—that case is + // reclaimed server-side, not by this timer. + idle.arm() + try { + const { done, value } = await reader.read() + idle.clear() + if (done) { + release() + streamController.close() + } else { + streamController.enqueue(value) + } + } catch (err) { + release() + streamController.error(err) + } + }, + async cancel(reason) { + try { + await reader.cancel(reason) + } finally { + release() + } + }, + }) +} + +/** + * Configuration for connecting to the API. + */ +export class ConnectionConfig { + public static envdPort = 49983 + + private static integration?: string + + private static readonly sdkUserAgentPrefix = 'e2b-js-sdk/' + + private static buildUserAgent() { + const userAgentParts = [`${ConnectionConfig.sdkUserAgentPrefix}${version}`] + + if (ConnectionConfig.integration) { + userAgentParts.push(ConnectionConfig.integration) + } + + return userAgentParts.join(' ') + } + + /** + * Set the `User-Agent` on `headers`: an explicitly provided value always + * wins; otherwise the SDK-built one, tagged with the current integration. + * + * An SDK-built value carried over from an earlier config (configs are + * rebuilt via `new ConnectionConfig({ ...config })`) is recognized by its + * prefix and rebuilt, so it stays in sync with the current integration. + */ + private static applyUserAgent(headers: Record) { + const userAgent = headers['User-Agent'] + + if ( + userAgent !== undefined && + !userAgent.startsWith(ConnectionConfig.sdkUserAgentPrefix) + ) { + return + } + + headers['User-Agent'] = ConnectionConfig.buildUserAgent() + } + + /** + * Identify traffic from an integration wrapping the E2B SDK by appending + * `integration` (e.g. `'e2b-code-interpreter/0.1.0'`) to the `User-Agent` + * header of every request. + * + * Call once at startup, before any `ConnectionConfig` is constructed — + * configs read the value at construction time. Pass `undefined` to clear. + * + * @internal + * @hidden + * @hide + */ + static setIntegration(integration: string | undefined) { + ConnectionConfig.integration = integration + } + + readonly debug: boolean + readonly domain: string + readonly apiUrl: string + readonly sandboxUrl?: string + readonly logger?: Logger + + readonly requestTimeoutMs: number + + readonly apiKey?: string + readonly validateApiKey: boolean + /** + * @deprecated Pass the token through `apiHeaders` instead. + */ + readonly accessToken?: string + + readonly headers?: Record + + readonly proxy?: string + + constructor(opts?: ConnectionOpts) { + this.apiKey = opts?.apiKey || ConnectionConfig.apiKey + this.validateApiKey = + opts?.validateApiKey ?? ConnectionConfig.validateApiKey + this.debug = opts?.debug ?? ConnectionConfig.debug + this.domain = opts?.domain || ConnectionConfig.domain + this.accessToken = opts?.accessToken || ConnectionConfig.accessToken + this.requestTimeoutMs = opts?.requestTimeoutMs ?? REQUEST_TIMEOUT_MS + this.logger = opts?.logger + this.headers = { ...(opts?.headers ?? {}), ...(opts?.apiHeaders ?? {}) } + ConnectionConfig.applyUserAgent(this.headers) + this.proxy = opts?.proxy + + this.apiUrl = + opts?.apiUrl || + ConnectionConfig.apiUrl || + (this.debug ? 'http://localhost:3000' : `https://api.${this.domain}`) + + this.sandboxUrl = opts?.sandboxUrl || ConnectionConfig.sandboxUrl + } + + private static get domain() { + return getEnvVar('E2B_DOMAIN') || 'e2b.app' + } + + private static get apiUrl() { + return getEnvVar('E2B_API_URL') + } + + private static get sandboxUrl() { + return getEnvVar('E2B_SANDBOX_URL') + } + + private static get debug() { + return (getEnvVar('E2B_DEBUG') || 'false').toLowerCase() === 'true' + } + + private static get apiKey() { + return getEnvVar('E2B_API_KEY') + } + + private static get validateApiKey() { + return ( + (getEnvVar('E2B_VALIDATE_API_KEY') || 'true').toLowerCase() !== 'false' + ) + } + + private static get accessToken() { + return getEnvVar('E2B_ACCESS_TOKEN') + } + + getSignal(requestTimeoutMs?: number, signal?: AbortSignal) { + return buildRequestSignal(requestTimeoutMs ?? this.requestTimeoutMs, signal) + } + + getSandboxUrl( + sandboxId: string, + opts: { sandboxDomain: string; envdPort: number } + ) { + if (this.sandboxUrl) { + return this.sandboxUrl + } + + if (this.debug) { + return `http://${this.getHost(sandboxId, opts.envdPort, opts.sandboxDomain)}` + } + + const sandboxDomain = opts.sandboxDomain ?? this.domain + // The stable sandbox host is only guaranteed for E2B prod; the various other hosted domains may not serve sandbox. yet and will follow up once those are updated. + // Issue with cors from browser so holding off on using in browser as well. + if (runtime !== 'browser' && supportedDomains.includes(sandboxDomain)) { + return `https://sandbox.${sandboxDomain}` + } + + return `https://${this.getHost(sandboxId, opts.envdPort, sandboxDomain)}` + } + + getSandboxDirectUrl( + sandboxId: string, + opts: { sandboxDomain: string; envdPort: number } + ) { + if (this.sandboxUrl) { + return this.sandboxUrl + } + + if (this.debug) { + return `http://${this.getHost(sandboxId, opts.envdPort, opts.sandboxDomain)}` + } + + return `https://${this.getHost(sandboxId, opts.envdPort, opts.sandboxDomain)}` + } + + getHost(sandboxId: string, port: number, sandboxDomain: string) { + if (this.debug) { + return `localhost:${port}` + } + + return `${port}-${sandboxId}.${sandboxDomain ?? this.domain}` + } +} + +/** + * User used for the operation in the sandbox. + */ + +export const defaultUsername: Username = 'user' +export type Username = string diff --git a/packages/js-sdk/src/envd/api.ts b/packages/js-sdk/src/envd/api.ts new file mode 100644 index 0000000..673648c --- /dev/null +++ b/packages/js-sdk/src/envd/api.ts @@ -0,0 +1,231 @@ +import createClient from 'openapi-fetch' + +import type { components, paths } from './schema.gen' +import { ConnectionConfig } from '../connectionConfig' +import { createApiLogger } from '../logs' +import { + SandboxError, + InvalidArgumentError, + NotFoundError, + NotEnoughSpaceError, + SandboxNotFoundError, + formatSandboxTimeoutError, + AuthenticationError, + RateLimitError, + TimeoutError, +} from '../errors' +import { StartResponse, ConnectResponse } from './process/process_pb' +import { Code, ConnectError } from '@connectrpc/connect' +import { WatchDirResponse } from './filesystem/filesystem_pb' +import { isConnectionTerminatedMessage, SandboxHealthCheck } from './rpc' + +type ApiError = { message?: string } | string + +const DEFAULT_ERROR_MAP: Record Error> = { + 400: (message) => new InvalidArgumentError(message), + 401: (message) => new AuthenticationError(message), + 404: (message) => new NotFoundError(message), + 429: (message) => + new RateLimitError(`${message}: The requests are being rate limited.`), + 502: formatSandboxTimeoutError, + 507: (message) => new NotEnoughSpaceError(message), +} + +const HEALTH_CHECK_TIMEOUT_MS = 5_000 + +/** + * Probes the sandbox's envd health endpoint. + * + * @param envdApi - The envd API client of the sandbox. + * @returns `true` if the sandbox is running, `false` if it is not, `undefined` if its state could not be determined. + */ +export async function checkSandboxHealth( + envdApi: EnvdApiClient +): Promise { + try { + const res = await envdApi.api.GET('/health', { + signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), + }) + + if (res.response.status === 502) { + return false + } + if (res.response.ok) { + return true + } + + return undefined + } catch { + return undefined + } +} + +/** + * Handles transport-level fetch failures from envd API calls. When the connection was + * dropped mid-request, probes the sandbox health to tell apart the sandbox being killed + * from a transient network failure (e.g. a load balancer dropping the connection). + * + * @param err - The caught error, expected to be a fetch transport failure. + * @param checkHealth - Probe returning whether the sandbox is running, or `undefined` when unknown. + * @returns A `TimeoutError` when the connection was terminated mid-request and the sandbox is confirmed gone, or the original error otherwise. + */ +export async function handleEnvdApiFetchError( + err: unknown, + checkHealth?: SandboxHealthCheck +): Promise { + // A connection dropped mid-body surfaces as a fetch failure whose message varies + // by runtime (e.g. undici's 'terminated'); match every known variant. + if (err instanceof Error && isConnectionTerminatedMessage(err.message)) { + const running = checkHealth + ? await checkHealth().catch(() => undefined) + : undefined + + if (running === false) { + return new TimeoutError( + `${err.message}: The sandbox was killed or reached its end of life while the request was in flight.` + ) + } + } + + return err as Error +} + +/** + * Handles errors from envd API responses by mapping HTTP status codes to specific error types. + * + * @param res - The API response object containing an optional error and the raw `Response`. + * @param errorMap - Optional map of HTTP status codes to error factory functions that override the defaults. + * @returns The corresponding `Error` instance if an error is present, or `undefined` if the response is successful. + */ +export async function handleEnvdApiError( + res: { + error?: ApiError + response: Response + }, + errorMap?: Record Error> +) { + // openapi-fetch leaves `error` empty for non-2xx responses without content + // (undefined for Content-Length: 0, '' for an empty body without the + // header), so check the status instead + if (res.response.ok) { + return + } + + let message = + (typeof res.error === 'string' ? res.error : res.error?.message) ?? '' + + // openapi-fetch consumes the body when parsing the error, except for + // responses without content + if (!message && !res.response.bodyUsed) { + try { + message = await res.response.text() + } catch { + // ignore unreadable bodies + } + } + + message = message || res.response.statusText + + // Check if a custom error mapping is provided for this error code + if (errorMap && res.response.status in errorMap) { + return errorMap[res.response.status]?.(message) + } + + // Check if there is a default error mapping for this error code + if (res.response.status in DEFAULT_ERROR_MAP) { + return DEFAULT_ERROR_MAP[res.response.status]?.(message) + } + + // Fallback to a generic SandboxError if no specific mapping is found + return new SandboxError(`${res.response.status}: ${message}`) +} + +export async function handleProcessStartEvent( + events: AsyncIterable +) { + let startEvent: StartResponse | ConnectResponse + + try { + startEvent = (await events[Symbol.asyncIterator]().next()).value + } catch (err) { + if (err instanceof ConnectError) { + if (err.code === Code.Unavailable) { + throw new SandboxNotFoundError( + 'Sandbox is probably not running anymore' + ) + } + } + + throw err + } + if (startEvent.event?.event.case !== 'start') { + throw new Error('Expected start event') + } + + return startEvent.event.event.value.pid +} + +export async function handleWatchDirStartEvent( + events: AsyncIterable +) { + let startEvent: WatchDirResponse + + try { + startEvent = (await events[Symbol.asyncIterator]().next()).value + } catch (err) { + if (err instanceof ConnectError) { + if (err.code === Code.Unavailable) { + throw new SandboxNotFoundError( + 'Sandbox is probably not running anymore' + ) + } + } + + throw err + } + if (startEvent.event?.case !== 'start') { + throw new Error('Expected start event') + } + + return startEvent.event.value +} + +class EnvdApiClient { + readonly api: ReturnType> + readonly version: string + + constructor( + config: Pick & { + /** + * Sandbox-scoped envd access token, sent as the `X-Access-Token` header. + */ + envdAccessToken?: string + fetch?: (request: Request) => ReturnType + headers?: Record + }, + metadata: { + version: string + } + ) { + this.api = createClient({ + baseUrl: config.apiUrl, + fetch: config?.fetch, + headers: { + ...config?.headers, + ...(config.envdAccessToken && { + 'X-Access-Token': config.envdAccessToken, + }), + }, + // In HTTP 1.1, all connections are considered persistent unless declared otherwise + // keepalive: true, + }) + this.version = metadata.version + + if (config.logger) { + this.api.use(createApiLogger(config.logger)) + } + } +} + +export type { components, paths } +export { EnvdApiClient } diff --git a/packages/js-sdk/src/envd/filesystem/filesystem_connect.ts b/packages/js-sdk/src/envd/filesystem/filesystem_connect.ts new file mode 100644 index 0000000..0a30537 --- /dev/null +++ b/packages/js-sdk/src/envd/filesystem/filesystem_connect.ts @@ -0,0 +1,118 @@ +// @generated by protoc-gen-connect-es v1.6.1 with parameter "target=ts" +// @generated from file filesystem/filesystem.proto (package filesystem, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import { + CreateWatcherRequest, + CreateWatcherResponse, + GetWatcherEventsRequest, + GetWatcherEventsResponse, + ListDirRequest, + ListDirResponse, + MakeDirRequest, + MakeDirResponse, + MoveRequest, + MoveResponse, + RemoveRequest, + RemoveResponse, + RemoveWatcherRequest, + RemoveWatcherResponse, + StatRequest, + StatResponse, + WatchDirRequest, + WatchDirResponse, +} from './filesystem_pb.js' +import { MethodKind } from '@bufbuild/protobuf' + +/** + * @generated from service filesystem.Filesystem + */ +export const Filesystem = { + typeName: 'filesystem.Filesystem', + methods: { + /** + * @generated from rpc filesystem.Filesystem.Stat + */ + stat: { + name: 'Stat', + I: StatRequest, + O: StatResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc filesystem.Filesystem.MakeDir + */ + makeDir: { + name: 'MakeDir', + I: MakeDirRequest, + O: MakeDirResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc filesystem.Filesystem.Move + */ + move: { + name: 'Move', + I: MoveRequest, + O: MoveResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc filesystem.Filesystem.ListDir + */ + listDir: { + name: 'ListDir', + I: ListDirRequest, + O: ListDirResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc filesystem.Filesystem.Remove + */ + remove: { + name: 'Remove', + I: RemoveRequest, + O: RemoveResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc filesystem.Filesystem.WatchDir + */ + watchDir: { + name: 'WatchDir', + I: WatchDirRequest, + O: WatchDirResponse, + kind: MethodKind.ServerStreaming, + }, + /** + * Non-streaming versions of WatchDir + * + * @generated from rpc filesystem.Filesystem.CreateWatcher + */ + createWatcher: { + name: 'CreateWatcher', + I: CreateWatcherRequest, + O: CreateWatcherResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc filesystem.Filesystem.GetWatcherEvents + */ + getWatcherEvents: { + name: 'GetWatcherEvents', + I: GetWatcherEventsRequest, + O: GetWatcherEventsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc filesystem.Filesystem.RemoveWatcher + */ + removeWatcher: { + name: 'RemoveWatcher', + I: RemoveWatcherRequest, + O: RemoveWatcherResponse, + kind: MethodKind.Unary, + }, + }, +} as const diff --git a/packages/js-sdk/src/envd/filesystem/filesystem_pb.ts b/packages/js-sdk/src/envd/filesystem/filesystem_pb.ts new file mode 100644 index 0000000..c78f752 --- /dev/null +++ b/packages/js-sdk/src/envd/filesystem/filesystem_pb.ts @@ -0,0 +1,704 @@ +// @generated by protoc-gen-es v2.6.2 with parameter "target=ts" +// @generated from file filesystem/filesystem.proto (package filesystem, syntax proto3) +/* eslint-disable */ + +import type { + GenEnum, + GenFile, + GenMessage, + GenService, +} from '@bufbuild/protobuf/codegenv2' +import { + enumDesc, + fileDesc, + messageDesc, + serviceDesc, +} from '@bufbuild/protobuf/codegenv2' +import type { Timestamp } from '@bufbuild/protobuf/wkt' +import { file_google_protobuf_timestamp } from '@bufbuild/protobuf/wkt' +import type { Message } from '@bufbuild/protobuf' + +/** + * Describes the file filesystem/filesystem.proto. + */ +export const file_filesystem_filesystem: GenFile = + /*@__PURE__*/ + fileDesc( + 'ChtmaWxlc3lzdGVtL2ZpbGVzeXN0ZW0ucHJvdG8SCmZpbGVzeXN0ZW0iMgoLTW92ZVJlcXVlc3QSDgoGc291cmNlGAEgASgJEhMKC2Rlc3RpbmF0aW9uGAIgASgJIjQKDE1vdmVSZXNwb25zZRIkCgVlbnRyeRgBIAEoCzIVLmZpbGVzeXN0ZW0uRW50cnlJbmZvIh4KDk1ha2VEaXJSZXF1ZXN0EgwKBHBhdGgYASABKAkiNwoPTWFrZURpclJlc3BvbnNlEiQKBWVudHJ5GAEgASgLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm8iHQoNUmVtb3ZlUmVxdWVzdBIMCgRwYXRoGAEgASgJIhAKDlJlbW92ZVJlc3BvbnNlIhsKC1N0YXRSZXF1ZXN0EgwKBHBhdGgYASABKAkiNAoMU3RhdFJlc3BvbnNlEiQKBWVudHJ5GAEgASgLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm8i5QIKCUVudHJ5SW5mbxIMCgRuYW1lGAEgASgJEiIKBHR5cGUYAiABKA4yFC5maWxlc3lzdGVtLkZpbGVUeXBlEgwKBHBhdGgYAyABKAkSDAoEc2l6ZRgEIAEoAxIMCgRtb2RlGAUgASgNEhMKC3Blcm1pc3Npb25zGAYgASgJEg0KBW93bmVyGAcgASgJEg0KBWdyb3VwGAggASgJEjEKDW1vZGlmaWVkX3RpbWUYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhsKDnN5bWxpbmtfdGFyZ2V0GAogASgJSACIAQESNQoIbWV0YWRhdGEYCyADKAsyIy5maWxlc3lzdGVtLkVudHJ5SW5mby5NZXRhZGF0YUVudHJ5Gi8KDU1ldGFkYXRhRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIRCg9fc3ltbGlua190YXJnZXQiLQoOTGlzdERpclJlcXVlc3QSDAoEcGF0aBgBIAEoCRINCgVkZXB0aBgCIAEoDSI5Cg9MaXN0RGlyUmVzcG9uc2USJgoHZW50cmllcxgBIAMoCzIVLmZpbGVzeXN0ZW0uRW50cnlJbmZvImcKD1dhdGNoRGlyUmVxdWVzdBIMCgRwYXRoGAEgASgJEhEKCXJlY3Vyc2l2ZRgCIAEoCBIVCg1pbmNsdWRlX2VudHJ5GAMgASgIEhwKFGFsbG93X25ldHdvcmtfbW91bnRzGAQgASgIInkKD0ZpbGVzeXN0ZW1FdmVudBIMCgRuYW1lGAEgASgJEiMKBHR5cGUYAiABKA4yFS5maWxlc3lzdGVtLkV2ZW50VHlwZRIpCgVlbnRyeRgDIAEoCzIVLmZpbGVzeXN0ZW0uRW50cnlJbmZvSACIAQFCCAoGX2VudHJ5IuABChBXYXRjaERpclJlc3BvbnNlEjgKBXN0YXJ0GAEgASgLMicuZmlsZXN5c3RlbS5XYXRjaERpclJlc3BvbnNlLlN0YXJ0RXZlbnRIABIxCgpmaWxlc3lzdGVtGAIgASgLMhsuZmlsZXN5c3RlbS5GaWxlc3lzdGVtRXZlbnRIABI7CglrZWVwYWxpdmUYAyABKAsyJi5maWxlc3lzdGVtLldhdGNoRGlyUmVzcG9uc2UuS2VlcEFsaXZlSAAaDAoKU3RhcnRFdmVudBoLCglLZWVwQWxpdmVCBwoFZXZlbnQibAoUQ3JlYXRlV2F0Y2hlclJlcXVlc3QSDAoEcGF0aBgBIAEoCRIRCglyZWN1cnNpdmUYAiABKAgSFQoNaW5jbHVkZV9lbnRyeRgDIAEoCBIcChRhbGxvd19uZXR3b3JrX21vdW50cxgEIAEoCCIrChVDcmVhdGVXYXRjaGVyUmVzcG9uc2USEgoKd2F0Y2hlcl9pZBgBIAEoCSItChdHZXRXYXRjaGVyRXZlbnRzUmVxdWVzdBISCgp3YXRjaGVyX2lkGAEgASgJIkcKGEdldFdhdGNoZXJFdmVudHNSZXNwb25zZRIrCgZldmVudHMYASADKAsyGy5maWxlc3lzdGVtLkZpbGVzeXN0ZW1FdmVudCIqChRSZW1vdmVXYXRjaGVyUmVxdWVzdBISCgp3YXRjaGVyX2lkGAEgASgJIhcKFVJlbW92ZVdhdGNoZXJSZXNwb25zZSpSCghGaWxlVHlwZRIZChVGSUxFX1RZUEVfVU5TUEVDSUZJRUQQABISCg5GSUxFX1RZUEVfRklMRRABEhcKE0ZJTEVfVFlQRV9ESVJFQ1RPUlkQAiqYAQoJRXZlbnRUeXBlEhoKFkVWRU5UX1RZUEVfVU5TUEVDSUZJRUQQABIVChFFVkVOVF9UWVBFX0NSRUFURRABEhQKEEVWRU5UX1RZUEVfV1JJVEUQAhIVChFFVkVOVF9UWVBFX1JFTU9WRRADEhUKEUVWRU5UX1RZUEVfUkVOQU1FEAQSFAoQRVZFTlRfVFlQRV9DSE1PRBAFMp8FCgpGaWxlc3lzdGVtEjkKBFN0YXQSFy5maWxlc3lzdGVtLlN0YXRSZXF1ZXN0GhguZmlsZXN5c3RlbS5TdGF0UmVzcG9uc2USQgoHTWFrZURpchIaLmZpbGVzeXN0ZW0uTWFrZURpclJlcXVlc3QaGy5maWxlc3lzdGVtLk1ha2VEaXJSZXNwb25zZRI5CgRNb3ZlEhcuZmlsZXN5c3RlbS5Nb3ZlUmVxdWVzdBoYLmZpbGVzeXN0ZW0uTW92ZVJlc3BvbnNlEkIKB0xpc3REaXISGi5maWxlc3lzdGVtLkxpc3REaXJSZXF1ZXN0GhsuZmlsZXN5c3RlbS5MaXN0RGlyUmVzcG9uc2USPwoGUmVtb3ZlEhkuZmlsZXN5c3RlbS5SZW1vdmVSZXF1ZXN0GhouZmlsZXN5c3RlbS5SZW1vdmVSZXNwb25zZRJHCghXYXRjaERpchIbLmZpbGVzeXN0ZW0uV2F0Y2hEaXJSZXF1ZXN0GhwuZmlsZXN5c3RlbS5XYXRjaERpclJlc3BvbnNlMAESVAoNQ3JlYXRlV2F0Y2hlchIgLmZpbGVzeXN0ZW0uQ3JlYXRlV2F0Y2hlclJlcXVlc3QaIS5maWxlc3lzdGVtLkNyZWF0ZVdhdGNoZXJSZXNwb25zZRJdChBHZXRXYXRjaGVyRXZlbnRzEiMuZmlsZXN5c3RlbS5HZXRXYXRjaGVyRXZlbnRzUmVxdWVzdBokLmZpbGVzeXN0ZW0uR2V0V2F0Y2hlckV2ZW50c1Jlc3BvbnNlElQKDVJlbW92ZVdhdGNoZXISIC5maWxlc3lzdGVtLlJlbW92ZVdhdGNoZXJSZXF1ZXN0GiEuZmlsZXN5c3RlbS5SZW1vdmVXYXRjaGVyUmVzcG9uc2VCaQoOY29tLmZpbGVzeXN0ZW1CD0ZpbGVzeXN0ZW1Qcm90b1ABogIDRlhYqgIKRmlsZXN5c3RlbcoCCkZpbGVzeXN0ZW3iAhZGaWxlc3lzdGVtXEdQQk1ldGFkYXRh6gIKRmlsZXN5c3RlbWIGcHJvdG8z', + [file_google_protobuf_timestamp] + ) + +/** + * @generated from message filesystem.MoveRequest + */ +export type MoveRequest = Message<'filesystem.MoveRequest'> & { + /** + * @generated from field: string source = 1; + */ + source: string + + /** + * @generated from field: string destination = 2; + */ + destination: string +} + +/** + * Describes the message filesystem.MoveRequest. + * Use `create(MoveRequestSchema)` to create a new message. + */ +export const MoveRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 0) + +/** + * @generated from message filesystem.MoveResponse + */ +export type MoveResponse = Message<'filesystem.MoveResponse'> & { + /** + * @generated from field: filesystem.EntryInfo entry = 1; + */ + entry?: EntryInfo +} + +/** + * Describes the message filesystem.MoveResponse. + * Use `create(MoveResponseSchema)` to create a new message. + */ +export const MoveResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 1) + +/** + * @generated from message filesystem.MakeDirRequest + */ +export type MakeDirRequest = Message<'filesystem.MakeDirRequest'> & { + /** + * @generated from field: string path = 1; + */ + path: string +} + +/** + * Describes the message filesystem.MakeDirRequest. + * Use `create(MakeDirRequestSchema)` to create a new message. + */ +export const MakeDirRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 2) + +/** + * @generated from message filesystem.MakeDirResponse + */ +export type MakeDirResponse = Message<'filesystem.MakeDirResponse'> & { + /** + * @generated from field: filesystem.EntryInfo entry = 1; + */ + entry?: EntryInfo +} + +/** + * Describes the message filesystem.MakeDirResponse. + * Use `create(MakeDirResponseSchema)` to create a new message. + */ +export const MakeDirResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 3) + +/** + * @generated from message filesystem.RemoveRequest + */ +export type RemoveRequest = Message<'filesystem.RemoveRequest'> & { + /** + * @generated from field: string path = 1; + */ + path: string +} + +/** + * Describes the message filesystem.RemoveRequest. + * Use `create(RemoveRequestSchema)` to create a new message. + */ +export const RemoveRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 4) + +/** + * @generated from message filesystem.RemoveResponse + */ +export type RemoveResponse = Message<'filesystem.RemoveResponse'> & {} + +/** + * Describes the message filesystem.RemoveResponse. + * Use `create(RemoveResponseSchema)` to create a new message. + */ +export const RemoveResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 5) + +/** + * @generated from message filesystem.StatRequest + */ +export type StatRequest = Message<'filesystem.StatRequest'> & { + /** + * @generated from field: string path = 1; + */ + path: string +} + +/** + * Describes the message filesystem.StatRequest. + * Use `create(StatRequestSchema)` to create a new message. + */ +export const StatRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 6) + +/** + * @generated from message filesystem.StatResponse + */ +export type StatResponse = Message<'filesystem.StatResponse'> & { + /** + * @generated from field: filesystem.EntryInfo entry = 1; + */ + entry?: EntryInfo +} + +/** + * Describes the message filesystem.StatResponse. + * Use `create(StatResponseSchema)` to create a new message. + */ +export const StatResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 7) + +/** + * @generated from message filesystem.EntryInfo + */ +export type EntryInfo = Message<'filesystem.EntryInfo'> & { + /** + * @generated from field: string name = 1; + */ + name: string + + /** + * @generated from field: filesystem.FileType type = 2; + */ + type: FileType + + /** + * @generated from field: string path = 3; + */ + path: string + + /** + * @generated from field: int64 size = 4; + */ + size: bigint + + /** + * @generated from field: uint32 mode = 5; + */ + mode: number + + /** + * @generated from field: string permissions = 6; + */ + permissions: string + + /** + * @generated from field: string owner = 7; + */ + owner: string + + /** + * @generated from field: string group = 8; + */ + group: string + + /** + * @generated from field: google.protobuf.Timestamp modified_time = 9; + */ + modifiedTime?: Timestamp + + /** + * If the entry is a symlink, this field contains the target of the symlink. + * + * @generated from field: optional string symlink_target = 10; + */ + symlinkTarget?: string + + /** + * User-defined metadata stored as extended attributes (xattrs) on the file. + * Keys live under the `user.e2b.` xattr namespace; the prefix is stripped here. + * Plain `user.*` xattrs written by other tooling are not reflected. + * + * @generated from field: map metadata = 11; + */ + metadata: { [key: string]: string } +} + +/** + * Describes the message filesystem.EntryInfo. + * Use `create(EntryInfoSchema)` to create a new message. + */ +export const EntryInfoSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 8) + +/** + * @generated from message filesystem.ListDirRequest + */ +export type ListDirRequest = Message<'filesystem.ListDirRequest'> & { + /** + * @generated from field: string path = 1; + */ + path: string + + /** + * @generated from field: uint32 depth = 2; + */ + depth: number +} + +/** + * Describes the message filesystem.ListDirRequest. + * Use `create(ListDirRequestSchema)` to create a new message. + */ +export const ListDirRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 9) + +/** + * @generated from message filesystem.ListDirResponse + */ +export type ListDirResponse = Message<'filesystem.ListDirResponse'> & { + /** + * @generated from field: repeated filesystem.EntryInfo entries = 1; + */ + entries: EntryInfo[] +} + +/** + * Describes the message filesystem.ListDirResponse. + * Use `create(ListDirResponseSchema)` to create a new message. + */ +export const ListDirResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 10) + +/** + * @generated from message filesystem.WatchDirRequest + */ +export type WatchDirRequest = Message<'filesystem.WatchDirRequest'> & { + /** + * @generated from field: string path = 1; + */ + path: string + + /** + * @generated from field: bool recursive = 2; + */ + recursive: boolean + + /** + * If true, each FilesystemEvent includes the EntryInfo of the affected entry, when available. + * + * @generated from field: bool include_entry = 3; + */ + includeEntry: boolean + + /** + * If true, allows watching paths on network filesystem mounts (NFS, CIFS, SMB, FUSE). + * Events on network mounts may be unreliable or not delivered at all. + * + * @generated from field: bool allow_network_mounts = 4; + */ + allowNetworkMounts: boolean +} + +/** + * Describes the message filesystem.WatchDirRequest. + * Use `create(WatchDirRequestSchema)` to create a new message. + */ +export const WatchDirRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 11) + +/** + * @generated from message filesystem.FilesystemEvent + */ +export type FilesystemEvent = Message<'filesystem.FilesystemEvent'> & { + /** + * @generated from field: string name = 1; + */ + name: string + + /** + * @generated from field: filesystem.EventType type = 2; + */ + type: EventType + + /** + * Info of the entry that triggered the event. Only populated when include_entry + * was requested and the entry could be stat-ed (e.g. not set for remove/rename-away + * events, where the entry no longer exists at this path). + * + * @generated from field: optional filesystem.EntryInfo entry = 3; + */ + entry?: EntryInfo +} + +/** + * Describes the message filesystem.FilesystemEvent. + * Use `create(FilesystemEventSchema)` to create a new message. + */ +export const FilesystemEventSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 12) + +/** + * @generated from message filesystem.WatchDirResponse + */ +export type WatchDirResponse = Message<'filesystem.WatchDirResponse'> & { + /** + * @generated from oneof filesystem.WatchDirResponse.event + */ + event: + | { + /** + * @generated from field: filesystem.WatchDirResponse.StartEvent start = 1; + */ + value: WatchDirResponse_StartEvent + case: 'start' + } + | { + /** + * @generated from field: filesystem.FilesystemEvent filesystem = 2; + */ + value: FilesystemEvent + case: 'filesystem' + } + | { + /** + * @generated from field: filesystem.WatchDirResponse.KeepAlive keepalive = 3; + */ + value: WatchDirResponse_KeepAlive + case: 'keepalive' + } + | { case: undefined; value?: undefined } +} + +/** + * Describes the message filesystem.WatchDirResponse. + * Use `create(WatchDirResponseSchema)` to create a new message. + */ +export const WatchDirResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 13) + +/** + * @generated from message filesystem.WatchDirResponse.StartEvent + */ +export type WatchDirResponse_StartEvent = + Message<'filesystem.WatchDirResponse.StartEvent'> & {} + +/** + * Describes the message filesystem.WatchDirResponse.StartEvent. + * Use `create(WatchDirResponse_StartEventSchema)` to create a new message. + */ +export const WatchDirResponse_StartEventSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 13, 0) + +/** + * @generated from message filesystem.WatchDirResponse.KeepAlive + */ +export type WatchDirResponse_KeepAlive = + Message<'filesystem.WatchDirResponse.KeepAlive'> & {} + +/** + * Describes the message filesystem.WatchDirResponse.KeepAlive. + * Use `create(WatchDirResponse_KeepAliveSchema)` to create a new message. + */ +export const WatchDirResponse_KeepAliveSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 13, 1) + +/** + * @generated from message filesystem.CreateWatcherRequest + */ +export type CreateWatcherRequest = + Message<'filesystem.CreateWatcherRequest'> & { + /** + * @generated from field: string path = 1; + */ + path: string + + /** + * @generated from field: bool recursive = 2; + */ + recursive: boolean + + /** + * If true, each FilesystemEvent includes the EntryInfo of the affected entry, when available. + * + * @generated from field: bool include_entry = 3; + */ + includeEntry: boolean + + /** + * If true, allows watching paths on network filesystem mounts (NFS, CIFS, SMB, FUSE). + * Events on network mounts may be unreliable or not delivered at all. + * + * @generated from field: bool allow_network_mounts = 4; + */ + allowNetworkMounts: boolean + } + +/** + * Describes the message filesystem.CreateWatcherRequest. + * Use `create(CreateWatcherRequestSchema)` to create a new message. + */ +export const CreateWatcherRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 14) + +/** + * @generated from message filesystem.CreateWatcherResponse + */ +export type CreateWatcherResponse = + Message<'filesystem.CreateWatcherResponse'> & { + /** + * @generated from field: string watcher_id = 1; + */ + watcherId: string + } + +/** + * Describes the message filesystem.CreateWatcherResponse. + * Use `create(CreateWatcherResponseSchema)` to create a new message. + */ +export const CreateWatcherResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 15) + +/** + * @generated from message filesystem.GetWatcherEventsRequest + */ +export type GetWatcherEventsRequest = + Message<'filesystem.GetWatcherEventsRequest'> & { + /** + * @generated from field: string watcher_id = 1; + */ + watcherId: string + } + +/** + * Describes the message filesystem.GetWatcherEventsRequest. + * Use `create(GetWatcherEventsRequestSchema)` to create a new message. + */ +export const GetWatcherEventsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 16) + +/** + * @generated from message filesystem.GetWatcherEventsResponse + */ +export type GetWatcherEventsResponse = + Message<'filesystem.GetWatcherEventsResponse'> & { + /** + * @generated from field: repeated filesystem.FilesystemEvent events = 1; + */ + events: FilesystemEvent[] + } + +/** + * Describes the message filesystem.GetWatcherEventsResponse. + * Use `create(GetWatcherEventsResponseSchema)` to create a new message. + */ +export const GetWatcherEventsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 17) + +/** + * @generated from message filesystem.RemoveWatcherRequest + */ +export type RemoveWatcherRequest = + Message<'filesystem.RemoveWatcherRequest'> & { + /** + * @generated from field: string watcher_id = 1; + */ + watcherId: string + } + +/** + * Describes the message filesystem.RemoveWatcherRequest. + * Use `create(RemoveWatcherRequestSchema)` to create a new message. + */ +export const RemoveWatcherRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 18) + +/** + * @generated from message filesystem.RemoveWatcherResponse + */ +export type RemoveWatcherResponse = + Message<'filesystem.RemoveWatcherResponse'> & {} + +/** + * Describes the message filesystem.RemoveWatcherResponse. + * Use `create(RemoveWatcherResponseSchema)` to create a new message. + */ +export const RemoveWatcherResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_filesystem_filesystem, 19) + +/** + * @generated from enum filesystem.FileType + */ +export enum FileType { + /** + * @generated from enum value: FILE_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: FILE_TYPE_FILE = 1; + */ + FILE = 1, + + /** + * @generated from enum value: FILE_TYPE_DIRECTORY = 2; + */ + DIRECTORY = 2, +} + +/** + * Describes the enum filesystem.FileType. + */ +export const FileTypeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_filesystem_filesystem, 0) + +/** + * @generated from enum filesystem.EventType + */ +export enum EventType { + /** + * @generated from enum value: EVENT_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: EVENT_TYPE_CREATE = 1; + */ + CREATE = 1, + + /** + * @generated from enum value: EVENT_TYPE_WRITE = 2; + */ + WRITE = 2, + + /** + * @generated from enum value: EVENT_TYPE_REMOVE = 3; + */ + REMOVE = 3, + + /** + * @generated from enum value: EVENT_TYPE_RENAME = 4; + */ + RENAME = 4, + + /** + * @generated from enum value: EVENT_TYPE_CHMOD = 5; + */ + CHMOD = 5, +} + +/** + * Describes the enum filesystem.EventType. + */ +export const EventTypeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_filesystem_filesystem, 1) + +/** + * @generated from service filesystem.Filesystem + */ +export const Filesystem: GenService<{ + /** + * @generated from rpc filesystem.Filesystem.Stat + */ + stat: { + methodKind: 'unary' + input: typeof StatRequestSchema + output: typeof StatResponseSchema + } + /** + * @generated from rpc filesystem.Filesystem.MakeDir + */ + makeDir: { + methodKind: 'unary' + input: typeof MakeDirRequestSchema + output: typeof MakeDirResponseSchema + } + /** + * @generated from rpc filesystem.Filesystem.Move + */ + move: { + methodKind: 'unary' + input: typeof MoveRequestSchema + output: typeof MoveResponseSchema + } + /** + * @generated from rpc filesystem.Filesystem.ListDir + */ + listDir: { + methodKind: 'unary' + input: typeof ListDirRequestSchema + output: typeof ListDirResponseSchema + } + /** + * @generated from rpc filesystem.Filesystem.Remove + */ + remove: { + methodKind: 'unary' + input: typeof RemoveRequestSchema + output: typeof RemoveResponseSchema + } + /** + * @generated from rpc filesystem.Filesystem.WatchDir + */ + watchDir: { + methodKind: 'server_streaming' + input: typeof WatchDirRequestSchema + output: typeof WatchDirResponseSchema + } + /** + * Non-streaming versions of WatchDir + * + * @generated from rpc filesystem.Filesystem.CreateWatcher + */ + createWatcher: { + methodKind: 'unary' + input: typeof CreateWatcherRequestSchema + output: typeof CreateWatcherResponseSchema + } + /** + * @generated from rpc filesystem.Filesystem.GetWatcherEvents + */ + getWatcherEvents: { + methodKind: 'unary' + input: typeof GetWatcherEventsRequestSchema + output: typeof GetWatcherEventsResponseSchema + } + /** + * @generated from rpc filesystem.Filesystem.RemoveWatcher + */ + removeWatcher: { + methodKind: 'unary' + input: typeof RemoveWatcherRequestSchema + output: typeof RemoveWatcherResponseSchema + } +}> = /*@__PURE__*/ serviceDesc(file_filesystem_filesystem, 0) diff --git a/packages/js-sdk/src/envd/http2.ts b/packages/js-sdk/src/envd/http2.ts new file mode 100644 index 0000000..1de93db --- /dev/null +++ b/packages/js-sdk/src/envd/http2.ts @@ -0,0 +1,159 @@ +import { runtime } from '../utils' +import { parseInflightLimitEnv, parsePositiveIntEnv } from '../api/metadata' +import { limitConcurrency } from '../api/inflight' +import { + loadUndici, + toUndiciRequestInput, + type UndiciModule, + type UndiciRequestInit, +} from '../undici' + +type EnvdFetchOptions = { + connectionLimit?: number + inflightLimit?: number + proxy?: string + loadUndici?: () => Promise +} + +// Fetchers are cached per proxy so requests without a proxy keep sharing a +// single dispatcher while each distinct proxy URL gets its own. +const envdFetchers = new Map() +const envdRpcFetchers = new Map() +const DEFAULT_ENVD_CONNECTION_LIMIT = 10 +const DEFAULT_ENVD_RPC_CONNECTION_LIMIT = 200 +const DEFAULT_ENVD_INFLIGHT_LIMIT = 2000 +const DEFAULT_ENVD_RPC_INFLIGHT_LIMIT = 2000 + +export function createEnvdFetchForRuntime( + currentRuntime = runtime, + options: EnvdFetchOptions = {} +): typeof fetch { + if (currentRuntime !== 'node') { + return fetch + } + + let fetcherPromise: Promise | undefined + + return (async (input, init) => { + fetcherPromise ??= buildEnvdFetcher(options) + const fetcher = await fetcherPromise + + return fetcher(input, init) + }) as typeof fetch +} + +async function buildEnvdFetcher( + options: EnvdFetchOptions +): Promise { + const undici = await (options.loadUndici ?? loadUndici)() + const inflightLimit = options.inflightLimit ?? 0 + + if (!undici) { + return limitConcurrency(fetch, inflightLimit) + } + + const { Agent, ProxyAgent, fetch: undiciFetch } = undici + const connections = options.connectionLimit ?? DEFAULT_ENVD_CONNECTION_LIMIT + + const dispatcher = options.proxy + ? new ProxyAgent({ + uri: options.proxy, + allowH2: true, + connections, + }) + : new Agent({ + allowH2: true, + connections, + }) + const fetchWithDispatcher = undiciFetch as unknown as ( + input: RequestInfo | URL, + init?: UndiciRequestInit + ) => Promise + + const wrapped: typeof fetch = ((input, init) => { + const request = toUndiciRequestInput(input, init) + + return fetchWithDispatcher(request.input, { + ...request.init, + dispatcher, + }) + }) as typeof fetch + + return limitConcurrency(wrapped, inflightLimit) +} + +export function createEnvdFetch(proxy?: string): typeof fetch { + const key = proxy ?? '' + + const cached = envdFetchers.get(key) + if (cached) { + return cached + } + + // Keep one origin connection for short envd REST calls. If ALPN falls back + // to h1, this favors connection pressure over per-sandbox throughput. + const envdFetch = createEnvdFetchForRuntime(runtime, { + inflightLimit: getEnvdInflightLimit(), + proxy, + }) + envdFetchers.set(key, envdFetch) + + return envdFetch +} + +export function createEnvdRpcFetch(proxy?: string): typeof fetch { + const key = proxy ?? '' + + const cached = envdRpcFetchers.get(key) + if (cached) { + return cached + } + + const envdRpcFetch = createEnvdFetchForRuntime(runtime, { + connectionLimit: getEnvdRpcConnectionLimit(), + inflightLimit: getEnvdRpcInflightLimit(), + proxy, + }) + envdRpcFetchers.set(key, envdRpcFetch) + + return envdRpcFetch +} + +export function getEnvdRpcConnectionLimit(): number { + return parsePositiveIntEnv( + 'E2B_ENVD_RPC_CONNECTIONS', + DEFAULT_ENVD_RPC_CONNECTION_LIMIT + ) +} + +/** + * Returns the configured max number of envd REST requests (e.g. + * `files.read`/`files.write`) that can be in flight at once across all + * sandboxes in this SDK process, or `0` to disable the cap. + * + * Defaults to {@link DEFAULT_ENVD_INFLIGHT_LIMIT} ({@link 2000}). Override + * via `E2B_ENVD_INFLIGHT_REQUESTS` env var; set to `0` to disable the cap + * entirely. + */ +export function getEnvdInflightLimit(): number { + return parseInflightLimitEnv( + 'E2B_ENVD_INFLIGHT_REQUESTS', + DEFAULT_ENVD_INFLIGHT_LIMIT + ) +} + +/** + * Returns the configured max number of envd RPC requests that + * can be in flight at once across all sandboxes in this SDK process, + * or `0` to disable the cap. + * + * Defaults to {@link DEFAULT_ENVD_RPC_INFLIGHT_LIMIT} ({@link 2000}). Override + * via `E2B_ENVD_RPC_INFLIGHT_REQUESTS` env var; set to `0` to disable the cap + * entirely. + */ +export function getEnvdRpcInflightLimit(): number { + return parseInflightLimitEnv( + 'E2B_ENVD_RPC_INFLIGHT_REQUESTS', + DEFAULT_ENVD_RPC_INFLIGHT_LIMIT + ) +} diff --git a/packages/js-sdk/src/envd/process/process_connect.ts b/packages/js-sdk/src/envd/process/process_connect.ts new file mode 100644 index 0000000..10475ca --- /dev/null +++ b/packages/js-sdk/src/envd/process/process_connect.ts @@ -0,0 +1,110 @@ +// @generated by protoc-gen-connect-es v1.6.1 with parameter "target=ts" +// @generated from file process/process.proto (package process, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import { + CloseStdinRequest, + CloseStdinResponse, + ConnectRequest, + ConnectResponse, + ListRequest, + ListResponse, + SendInputRequest, + SendInputResponse, + SendSignalRequest, + SendSignalResponse, + StartRequest, + StartResponse, + StreamInputRequest, + StreamInputResponse, + UpdateRequest, + UpdateResponse, +} from './process_pb.js' +import { MethodKind } from '@bufbuild/protobuf' + +/** + * @generated from service process.Process + */ +export const Process = { + typeName: 'process.Process', + methods: { + /** + * @generated from rpc process.Process.List + */ + list: { + name: 'List', + I: ListRequest, + O: ListResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc process.Process.Connect + */ + connect: { + name: 'Connect', + I: ConnectRequest, + O: ConnectResponse, + kind: MethodKind.ServerStreaming, + }, + /** + * @generated from rpc process.Process.Start + */ + start: { + name: 'Start', + I: StartRequest, + O: StartResponse, + kind: MethodKind.ServerStreaming, + }, + /** + * @generated from rpc process.Process.Update + */ + update: { + name: 'Update', + I: UpdateRequest, + O: UpdateResponse, + kind: MethodKind.Unary, + }, + /** + * Client input stream ensures ordering of messages + * + * @generated from rpc process.Process.StreamInput + */ + streamInput: { + name: 'StreamInput', + I: StreamInputRequest, + O: StreamInputResponse, + kind: MethodKind.ClientStreaming, + }, + /** + * @generated from rpc process.Process.SendInput + */ + sendInput: { + name: 'SendInput', + I: SendInputRequest, + O: SendInputResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc process.Process.SendSignal + */ + sendSignal: { + name: 'SendSignal', + I: SendSignalRequest, + O: SendSignalResponse, + kind: MethodKind.Unary, + }, + /** + * Close stdin to signal EOF to the process. + * Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead. + * + * @generated from rpc process.Process.CloseStdin + */ + closeStdin: { + name: 'CloseStdin', + I: CloseStdinRequest, + O: CloseStdinResponse, + kind: MethodKind.Unary, + }, + }, +} as const diff --git a/packages/js-sdk/src/envd/process/process_pb.ts b/packages/js-sdk/src/envd/process/process_pb.ts new file mode 100644 index 0000000..1bdcdc4 --- /dev/null +++ b/packages/js-sdk/src/envd/process/process_pb.ts @@ -0,0 +1,812 @@ +// @generated by protoc-gen-es v2.6.2 with parameter "target=ts" +// @generated from file process/process.proto (package process, syntax proto3) +/* eslint-disable */ + +import type { + GenEnum, + GenFile, + GenMessage, + GenService, +} from '@bufbuild/protobuf/codegenv2' +import { + enumDesc, + fileDesc, + messageDesc, + serviceDesc, +} from '@bufbuild/protobuf/codegenv2' +import type { Message } from '@bufbuild/protobuf' + +/** + * Describes the file process/process.proto. + */ +export const file_process_process: GenFile = + /*@__PURE__*/ + fileDesc( + 'ChVwcm9jZXNzL3Byb2Nlc3MucHJvdG8SB3Byb2Nlc3MiSgoDUFRZEh8KBHNpemUYASABKAsyES5wcm9jZXNzLlBUWS5TaXplGiIKBFNpemUSDAoEY29scxgBIAEoDRIMCgRyb3dzGAIgASgNIqEBCg1Qcm9jZXNzQ29uZmlnEgsKA2NtZBgBIAEoCRIMCgRhcmdzGAIgAygJEi4KBGVudnMYAyADKAsyIC5wcm9jZXNzLlByb2Nlc3NDb25maWcuRW52c0VudHJ5EhAKA2N3ZBgEIAEoCUgAiAEBGisKCUVudnNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBQgYKBF9jd2QiDQoLTGlzdFJlcXVlc3QiXAoLUHJvY2Vzc0luZm8SJgoGY29uZmlnGAEgASgLMhYucHJvY2Vzcy5Qcm9jZXNzQ29uZmlnEgsKA3BpZBgCIAEoDRIQCgN0YWcYAyABKAlIAIgBAUIGCgRfdGFnIjcKDExpc3RSZXNwb25zZRInCglwcm9jZXNzZXMYASADKAsyFC5wcm9jZXNzLlByb2Nlc3NJbmZvIpcBCgxTdGFydFJlcXVlc3QSJwoHcHJvY2VzcxgBIAEoCzIWLnByb2Nlc3MuUHJvY2Vzc0NvbmZpZxIeCgNwdHkYAiABKAsyDC5wcm9jZXNzLlBUWUgAiAEBEhAKA3RhZxgDIAEoCUgBiAEBEhIKBXN0ZGluGAQgASgISAKIAQFCBgoEX3B0eUIGCgRfdGFnQggKBl9zdGRpbiJiCg1VcGRhdGVSZXF1ZXN0EikKB3Byb2Nlc3MYASABKAsyGC5wcm9jZXNzLlByb2Nlc3NTZWxlY3RvchIeCgNwdHkYAiABKAsyDC5wcm9jZXNzLlBUWUgAiAEBQgYKBF9wdHkiEAoOVXBkYXRlUmVzcG9uc2UirwMKDFByb2Nlc3NFdmVudBIxCgVzdGFydBgBIAEoCzIgLnByb2Nlc3MuUHJvY2Vzc0V2ZW50LlN0YXJ0RXZlbnRIABIvCgRkYXRhGAIgASgLMh8ucHJvY2Vzcy5Qcm9jZXNzRXZlbnQuRGF0YUV2ZW50SAASLQoDZW5kGAMgASgLMh4ucHJvY2Vzcy5Qcm9jZXNzRXZlbnQuRW5kRXZlbnRIABI0CglrZWVwYWxpdmUYBCABKAsyHy5wcm9jZXNzLlByb2Nlc3NFdmVudC5LZWVwQWxpdmVIABoZCgpTdGFydEV2ZW50EgsKA3BpZBgBIAEoDRpICglEYXRhRXZlbnQSEAoGc3Rkb3V0GAEgASgMSAASEAoGc3RkZXJyGAIgASgMSAASDQoDcHR5GAMgASgMSABCCAoGb3V0cHV0GlsKCEVuZEV2ZW50EhEKCWV4aXRfY29kZRgBIAEoERIOCgZleGl0ZWQYAiABKAgSDgoGc3RhdHVzGAMgASgJEhIKBWVycm9yGAQgASgJSACIAQFCCAoGX2Vycm9yGgsKCUtlZXBBbGl2ZUIHCgVldmVudCI1Cg1TdGFydFJlc3BvbnNlEiQKBWV2ZW50GAEgASgLMhUucHJvY2Vzcy5Qcm9jZXNzRXZlbnQiNwoPQ29ubmVjdFJlc3BvbnNlEiQKBWV2ZW50GAEgASgLMhUucHJvY2Vzcy5Qcm9jZXNzRXZlbnQiYwoQU2VuZElucHV0UmVxdWVzdBIpCgdwcm9jZXNzGAEgASgLMhgucHJvY2Vzcy5Qcm9jZXNzU2VsZWN0b3ISJAoFaW5wdXQYAiABKAsyFS5wcm9jZXNzLlByb2Nlc3NJbnB1dCITChFTZW5kSW5wdXRSZXNwb25zZSI3CgxQcm9jZXNzSW5wdXQSDwoFc3RkaW4YASABKAxIABINCgNwdHkYAiABKAxIAEIHCgVpbnB1dCLCAgoSU3RyZWFtSW5wdXRSZXF1ZXN0EjcKBXN0YXJ0GAEgASgLMiYucHJvY2Vzcy5TdHJlYW1JbnB1dFJlcXVlc3QuU3RhcnRFdmVudEgAEjUKBGRhdGEYAiABKAsyJS5wcm9jZXNzLlN0cmVhbUlucHV0UmVxdWVzdC5EYXRhRXZlbnRIABI6CglrZWVwYWxpdmUYAyABKAsyJS5wcm9jZXNzLlN0cmVhbUlucHV0UmVxdWVzdC5LZWVwQWxpdmVIABo3CgpTdGFydEV2ZW50EikKB3Byb2Nlc3MYASABKAsyGC5wcm9jZXNzLlByb2Nlc3NTZWxlY3RvchoxCglEYXRhRXZlbnQSJAoFaW5wdXQYAiABKAsyFS5wcm9jZXNzLlByb2Nlc3NJbnB1dBoLCglLZWVwQWxpdmVCBwoFZXZlbnQiFQoTU3RyZWFtSW5wdXRSZXNwb25zZSJfChFTZW5kU2lnbmFsUmVxdWVzdBIpCgdwcm9jZXNzGAEgASgLMhgucHJvY2Vzcy5Qcm9jZXNzU2VsZWN0b3ISHwoGc2lnbmFsGAIgASgOMg8ucHJvY2Vzcy5TaWduYWwiFAoSU2VuZFNpZ25hbFJlc3BvbnNlIj4KEUNsb3NlU3RkaW5SZXF1ZXN0EikKB3Byb2Nlc3MYASABKAsyGC5wcm9jZXNzLlByb2Nlc3NTZWxlY3RvciIUChJDbG9zZVN0ZGluUmVzcG9uc2UiOwoOQ29ubmVjdFJlcXVlc3QSKQoHcHJvY2VzcxgBIAEoCzIYLnByb2Nlc3MuUHJvY2Vzc1NlbGVjdG9yIjsKD1Byb2Nlc3NTZWxlY3RvchINCgNwaWQYASABKA1IABINCgN0YWcYAiABKAlIAEIKCghzZWxlY3RvcipICgZTaWduYWwSFgoSU0lHTkFMX1VOU1BFQ0lGSUVEEAASEgoOU0lHTkFMX1NJR1RFUk0QDxISCg5TSUdOQUxfU0lHS0lMTBAJMpEECgdQcm9jZXNzEjMKBExpc3QSFC5wcm9jZXNzLkxpc3RSZXF1ZXN0GhUucHJvY2Vzcy5MaXN0UmVzcG9uc2USPgoHQ29ubmVjdBIXLnByb2Nlc3MuQ29ubmVjdFJlcXVlc3QaGC5wcm9jZXNzLkNvbm5lY3RSZXNwb25zZTABEjgKBVN0YXJ0EhUucHJvY2Vzcy5TdGFydFJlcXVlc3QaFi5wcm9jZXNzLlN0YXJ0UmVzcG9uc2UwARI5CgZVcGRhdGUSFi5wcm9jZXNzLlVwZGF0ZVJlcXVlc3QaFy5wcm9jZXNzLlVwZGF0ZVJlc3BvbnNlEkoKC1N0cmVhbUlucHV0EhsucHJvY2Vzcy5TdHJlYW1JbnB1dFJlcXVlc3QaHC5wcm9jZXNzLlN0cmVhbUlucHV0UmVzcG9uc2UoARJCCglTZW5kSW5wdXQSGS5wcm9jZXNzLlNlbmRJbnB1dFJlcXVlc3QaGi5wcm9jZXNzLlNlbmRJbnB1dFJlc3BvbnNlEkUKClNlbmRTaWduYWwSGi5wcm9jZXNzLlNlbmRTaWduYWxSZXF1ZXN0GhsucHJvY2Vzcy5TZW5kU2lnbmFsUmVzcG9uc2USRQoKQ2xvc2VTdGRpbhIaLnByb2Nlc3MuQ2xvc2VTdGRpblJlcXVlc3QaGy5wcm9jZXNzLkNsb3NlU3RkaW5SZXNwb25zZUJXCgtjb20ucHJvY2Vzc0IMUHJvY2Vzc1Byb3RvUAGiAgNQWFiqAgdQcm9jZXNzygIHUHJvY2Vzc+ICE1Byb2Nlc3NcR1BCTWV0YWRhdGHqAgdQcm9jZXNzYgZwcm90bzM' + ) + +/** + * @generated from message process.PTY + */ +export type PTY = Message<'process.PTY'> & { + /** + * @generated from field: process.PTY.Size size = 1; + */ + size?: PTY_Size +} + +/** + * Describes the message process.PTY. + * Use `create(PTYSchema)` to create a new message. + */ +export const PTYSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 0) + +/** + * @generated from message process.PTY.Size + */ +export type PTY_Size = Message<'process.PTY.Size'> & { + /** + * @generated from field: uint32 cols = 1; + */ + cols: number + + /** + * @generated from field: uint32 rows = 2; + */ + rows: number +} + +/** + * Describes the message process.PTY.Size. + * Use `create(PTY_SizeSchema)` to create a new message. + */ +export const PTY_SizeSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 0, 0) + +/** + * @generated from message process.ProcessConfig + */ +export type ProcessConfig = Message<'process.ProcessConfig'> & { + /** + * @generated from field: string cmd = 1; + */ + cmd: string + + /** + * @generated from field: repeated string args = 2; + */ + args: string[] + + /** + * @generated from field: map envs = 3; + */ + envs: { [key: string]: string } + + /** + * @generated from field: optional string cwd = 4; + */ + cwd?: string +} + +/** + * Describes the message process.ProcessConfig. + * Use `create(ProcessConfigSchema)` to create a new message. + */ +export const ProcessConfigSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 1) + +/** + * @generated from message process.ListRequest + */ +export type ListRequest = Message<'process.ListRequest'> & {} + +/** + * Describes the message process.ListRequest. + * Use `create(ListRequestSchema)` to create a new message. + */ +export const ListRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 2) + +/** + * @generated from message process.ProcessInfo + */ +export type ProcessInfo = Message<'process.ProcessInfo'> & { + /** + * @generated from field: process.ProcessConfig config = 1; + */ + config?: ProcessConfig + + /** + * @generated from field: uint32 pid = 2; + */ + pid: number + + /** + * @generated from field: optional string tag = 3; + */ + tag?: string +} + +/** + * Describes the message process.ProcessInfo. + * Use `create(ProcessInfoSchema)` to create a new message. + */ +export const ProcessInfoSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 3) + +/** + * @generated from message process.ListResponse + */ +export type ListResponse = Message<'process.ListResponse'> & { + /** + * @generated from field: repeated process.ProcessInfo processes = 1; + */ + processes: ProcessInfo[] +} + +/** + * Describes the message process.ListResponse. + * Use `create(ListResponseSchema)` to create a new message. + */ +export const ListResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 4) + +/** + * @generated from message process.StartRequest + */ +export type StartRequest = Message<'process.StartRequest'> & { + /** + * @generated from field: process.ProcessConfig process = 1; + */ + process?: ProcessConfig + + /** + * @generated from field: optional process.PTY pty = 2; + */ + pty?: PTY + + /** + * @generated from field: optional string tag = 3; + */ + tag?: string + + /** + * @generated from field: optional bool stdin = 4; + */ + stdin?: boolean +} + +/** + * Describes the message process.StartRequest. + * Use `create(StartRequestSchema)` to create a new message. + */ +export const StartRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 5) + +/** + * @generated from message process.UpdateRequest + */ +export type UpdateRequest = Message<'process.UpdateRequest'> & { + /** + * @generated from field: process.ProcessSelector process = 1; + */ + process?: ProcessSelector + + /** + * @generated from field: optional process.PTY pty = 2; + */ + pty?: PTY +} + +/** + * Describes the message process.UpdateRequest. + * Use `create(UpdateRequestSchema)` to create a new message. + */ +export const UpdateRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 6) + +/** + * @generated from message process.UpdateResponse + */ +export type UpdateResponse = Message<'process.UpdateResponse'> & {} + +/** + * Describes the message process.UpdateResponse. + * Use `create(UpdateResponseSchema)` to create a new message. + */ +export const UpdateResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 7) + +/** + * @generated from message process.ProcessEvent + */ +export type ProcessEvent = Message<'process.ProcessEvent'> & { + /** + * @generated from oneof process.ProcessEvent.event + */ + event: + | { + /** + * @generated from field: process.ProcessEvent.StartEvent start = 1; + */ + value: ProcessEvent_StartEvent + case: 'start' + } + | { + /** + * @generated from field: process.ProcessEvent.DataEvent data = 2; + */ + value: ProcessEvent_DataEvent + case: 'data' + } + | { + /** + * @generated from field: process.ProcessEvent.EndEvent end = 3; + */ + value: ProcessEvent_EndEvent + case: 'end' + } + | { + /** + * @generated from field: process.ProcessEvent.KeepAlive keepalive = 4; + */ + value: ProcessEvent_KeepAlive + case: 'keepalive' + } + | { case: undefined; value?: undefined } +} + +/** + * Describes the message process.ProcessEvent. + * Use `create(ProcessEventSchema)` to create a new message. + */ +export const ProcessEventSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 8) + +/** + * @generated from message process.ProcessEvent.StartEvent + */ +export type ProcessEvent_StartEvent = + Message<'process.ProcessEvent.StartEvent'> & { + /** + * @generated from field: uint32 pid = 1; + */ + pid: number + } + +/** + * Describes the message process.ProcessEvent.StartEvent. + * Use `create(ProcessEvent_StartEventSchema)` to create a new message. + */ +export const ProcessEvent_StartEventSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 8, 0) + +/** + * @generated from message process.ProcessEvent.DataEvent + */ +export type ProcessEvent_DataEvent = + Message<'process.ProcessEvent.DataEvent'> & { + /** + * @generated from oneof process.ProcessEvent.DataEvent.output + */ + output: + | { + /** + * @generated from field: bytes stdout = 1; + */ + value: Uint8Array + case: 'stdout' + } + | { + /** + * @generated from field: bytes stderr = 2; + */ + value: Uint8Array + case: 'stderr' + } + | { + /** + * @generated from field: bytes pty = 3; + */ + value: Uint8Array + case: 'pty' + } + | { case: undefined; value?: undefined } + } + +/** + * Describes the message process.ProcessEvent.DataEvent. + * Use `create(ProcessEvent_DataEventSchema)` to create a new message. + */ +export const ProcessEvent_DataEventSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 8, 1) + +/** + * @generated from message process.ProcessEvent.EndEvent + */ +export type ProcessEvent_EndEvent = Message<'process.ProcessEvent.EndEvent'> & { + /** + * @generated from field: sint32 exit_code = 1; + */ + exitCode: number + + /** + * @generated from field: bool exited = 2; + */ + exited: boolean + + /** + * @generated from field: string status = 3; + */ + status: string + + /** + * @generated from field: optional string error = 4; + */ + error?: string +} + +/** + * Describes the message process.ProcessEvent.EndEvent. + * Use `create(ProcessEvent_EndEventSchema)` to create a new message. + */ +export const ProcessEvent_EndEventSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 8, 2) + +/** + * @generated from message process.ProcessEvent.KeepAlive + */ +export type ProcessEvent_KeepAlive = + Message<'process.ProcessEvent.KeepAlive'> & {} + +/** + * Describes the message process.ProcessEvent.KeepAlive. + * Use `create(ProcessEvent_KeepAliveSchema)` to create a new message. + */ +export const ProcessEvent_KeepAliveSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 8, 3) + +/** + * @generated from message process.StartResponse + */ +export type StartResponse = Message<'process.StartResponse'> & { + /** + * @generated from field: process.ProcessEvent event = 1; + */ + event?: ProcessEvent +} + +/** + * Describes the message process.StartResponse. + * Use `create(StartResponseSchema)` to create a new message. + */ +export const StartResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 9) + +/** + * @generated from message process.ConnectResponse + */ +export type ConnectResponse = Message<'process.ConnectResponse'> & { + /** + * @generated from field: process.ProcessEvent event = 1; + */ + event?: ProcessEvent +} + +/** + * Describes the message process.ConnectResponse. + * Use `create(ConnectResponseSchema)` to create a new message. + */ +export const ConnectResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 10) + +/** + * @generated from message process.SendInputRequest + */ +export type SendInputRequest = Message<'process.SendInputRequest'> & { + /** + * @generated from field: process.ProcessSelector process = 1; + */ + process?: ProcessSelector + + /** + * @generated from field: process.ProcessInput input = 2; + */ + input?: ProcessInput +} + +/** + * Describes the message process.SendInputRequest. + * Use `create(SendInputRequestSchema)` to create a new message. + */ +export const SendInputRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 11) + +/** + * @generated from message process.SendInputResponse + */ +export type SendInputResponse = Message<'process.SendInputResponse'> & {} + +/** + * Describes the message process.SendInputResponse. + * Use `create(SendInputResponseSchema)` to create a new message. + */ +export const SendInputResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 12) + +/** + * @generated from message process.ProcessInput + */ +export type ProcessInput = Message<'process.ProcessInput'> & { + /** + * @generated from oneof process.ProcessInput.input + */ + input: + | { + /** + * @generated from field: bytes stdin = 1; + */ + value: Uint8Array + case: 'stdin' + } + | { + /** + * @generated from field: bytes pty = 2; + */ + value: Uint8Array + case: 'pty' + } + | { case: undefined; value?: undefined } +} + +/** + * Describes the message process.ProcessInput. + * Use `create(ProcessInputSchema)` to create a new message. + */ +export const ProcessInputSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 13) + +/** + * @generated from message process.StreamInputRequest + */ +export type StreamInputRequest = Message<'process.StreamInputRequest'> & { + /** + * @generated from oneof process.StreamInputRequest.event + */ + event: + | { + /** + * @generated from field: process.StreamInputRequest.StartEvent start = 1; + */ + value: StreamInputRequest_StartEvent + case: 'start' + } + | { + /** + * @generated from field: process.StreamInputRequest.DataEvent data = 2; + */ + value: StreamInputRequest_DataEvent + case: 'data' + } + | { + /** + * @generated from field: process.StreamInputRequest.KeepAlive keepalive = 3; + */ + value: StreamInputRequest_KeepAlive + case: 'keepalive' + } + | { case: undefined; value?: undefined } +} + +/** + * Describes the message process.StreamInputRequest. + * Use `create(StreamInputRequestSchema)` to create a new message. + */ +export const StreamInputRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 14) + +/** + * @generated from message process.StreamInputRequest.StartEvent + */ +export type StreamInputRequest_StartEvent = + Message<'process.StreamInputRequest.StartEvent'> & { + /** + * @generated from field: process.ProcessSelector process = 1; + */ + process?: ProcessSelector + } + +/** + * Describes the message process.StreamInputRequest.StartEvent. + * Use `create(StreamInputRequest_StartEventSchema)` to create a new message. + */ +export const StreamInputRequest_StartEventSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 14, 0) + +/** + * @generated from message process.StreamInputRequest.DataEvent + */ +export type StreamInputRequest_DataEvent = + Message<'process.StreamInputRequest.DataEvent'> & { + /** + * @generated from field: process.ProcessInput input = 2; + */ + input?: ProcessInput + } + +/** + * Describes the message process.StreamInputRequest.DataEvent. + * Use `create(StreamInputRequest_DataEventSchema)` to create a new message. + */ +export const StreamInputRequest_DataEventSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 14, 1) + +/** + * @generated from message process.StreamInputRequest.KeepAlive + */ +export type StreamInputRequest_KeepAlive = + Message<'process.StreamInputRequest.KeepAlive'> & {} + +/** + * Describes the message process.StreamInputRequest.KeepAlive. + * Use `create(StreamInputRequest_KeepAliveSchema)` to create a new message. + */ +export const StreamInputRequest_KeepAliveSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 14, 2) + +/** + * @generated from message process.StreamInputResponse + */ +export type StreamInputResponse = Message<'process.StreamInputResponse'> & {} + +/** + * Describes the message process.StreamInputResponse. + * Use `create(StreamInputResponseSchema)` to create a new message. + */ +export const StreamInputResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 15) + +/** + * @generated from message process.SendSignalRequest + */ +export type SendSignalRequest = Message<'process.SendSignalRequest'> & { + /** + * @generated from field: process.ProcessSelector process = 1; + */ + process?: ProcessSelector + + /** + * @generated from field: process.Signal signal = 2; + */ + signal: Signal +} + +/** + * Describes the message process.SendSignalRequest. + * Use `create(SendSignalRequestSchema)` to create a new message. + */ +export const SendSignalRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 16) + +/** + * @generated from message process.SendSignalResponse + */ +export type SendSignalResponse = Message<'process.SendSignalResponse'> & {} + +/** + * Describes the message process.SendSignalResponse. + * Use `create(SendSignalResponseSchema)` to create a new message. + */ +export const SendSignalResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 17) + +/** + * @generated from message process.CloseStdinRequest + */ +export type CloseStdinRequest = Message<'process.CloseStdinRequest'> & { + /** + * @generated from field: process.ProcessSelector process = 1; + */ + process?: ProcessSelector +} + +/** + * Describes the message process.CloseStdinRequest. + * Use `create(CloseStdinRequestSchema)` to create a new message. + */ +export const CloseStdinRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 18) + +/** + * @generated from message process.CloseStdinResponse + */ +export type CloseStdinResponse = Message<'process.CloseStdinResponse'> & {} + +/** + * Describes the message process.CloseStdinResponse. + * Use `create(CloseStdinResponseSchema)` to create a new message. + */ +export const CloseStdinResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 19) + +/** + * @generated from message process.ConnectRequest + */ +export type ConnectRequest = Message<'process.ConnectRequest'> & { + /** + * @generated from field: process.ProcessSelector process = 1; + */ + process?: ProcessSelector +} + +/** + * Describes the message process.ConnectRequest. + * Use `create(ConnectRequestSchema)` to create a new message. + */ +export const ConnectRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 20) + +/** + * @generated from message process.ProcessSelector + */ +export type ProcessSelector = Message<'process.ProcessSelector'> & { + /** + * @generated from oneof process.ProcessSelector.selector + */ + selector: + | { + /** + * @generated from field: uint32 pid = 1; + */ + value: number + case: 'pid' + } + | { + /** + * @generated from field: string tag = 2; + */ + value: string + case: 'tag' + } + | { case: undefined; value?: undefined } +} + +/** + * Describes the message process.ProcessSelector. + * Use `create(ProcessSelectorSchema)` to create a new message. + */ +export const ProcessSelectorSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_process_process, 21) + +/** + * @generated from enum process.Signal + */ +export enum Signal { + /** + * @generated from enum value: SIGNAL_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SIGNAL_SIGTERM = 15; + */ + SIGTERM = 15, + + /** + * @generated from enum value: SIGNAL_SIGKILL = 9; + */ + SIGKILL = 9, +} + +/** + * Describes the enum process.Signal. + */ +export const SignalSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_process_process, 0) + +/** + * @generated from service process.Process + */ +export const Process: GenService<{ + /** + * @generated from rpc process.Process.List + */ + list: { + methodKind: 'unary' + input: typeof ListRequestSchema + output: typeof ListResponseSchema + } + /** + * @generated from rpc process.Process.Connect + */ + connect: { + methodKind: 'server_streaming' + input: typeof ConnectRequestSchema + output: typeof ConnectResponseSchema + } + /** + * @generated from rpc process.Process.Start + */ + start: { + methodKind: 'server_streaming' + input: typeof StartRequestSchema + output: typeof StartResponseSchema + } + /** + * @generated from rpc process.Process.Update + */ + update: { + methodKind: 'unary' + input: typeof UpdateRequestSchema + output: typeof UpdateResponseSchema + } + /** + * Client input stream ensures ordering of messages + * + * @generated from rpc process.Process.StreamInput + */ + streamInput: { + methodKind: 'client_streaming' + input: typeof StreamInputRequestSchema + output: typeof StreamInputResponseSchema + } + /** + * @generated from rpc process.Process.SendInput + */ + sendInput: { + methodKind: 'unary' + input: typeof SendInputRequestSchema + output: typeof SendInputResponseSchema + } + /** + * @generated from rpc process.Process.SendSignal + */ + sendSignal: { + methodKind: 'unary' + input: typeof SendSignalRequestSchema + output: typeof SendSignalResponseSchema + } + /** + * Close stdin to signal EOF to the process. + * Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead. + * + * @generated from rpc process.Process.CloseStdin + */ + closeStdin: { + methodKind: 'unary' + input: typeof CloseStdinRequestSchema + output: typeof CloseStdinResponseSchema + } +}> = /*@__PURE__*/ serviceDesc(file_process_process, 0) diff --git a/packages/js-sdk/src/envd/rpc.ts b/packages/js-sdk/src/envd/rpc.ts new file mode 100644 index 0000000..64234fa --- /dev/null +++ b/packages/js-sdk/src/envd/rpc.ts @@ -0,0 +1,178 @@ +import { Code, ConnectError } from '@connectrpc/connect' +import { runtime } from '../utils' + +import { compareVersions } from 'compare-versions' +import { defaultUsername } from '../connectionConfig' +import { + AuthenticationError, + formatSandboxTimeoutError, + InvalidArgumentError, + NotFoundError, + RateLimitError, + SandboxError, + TimeoutError, +} from '../errors' +import { ENVD_DEFAULT_USER } from './versions' + +/** + * Result of a sandbox health probe: `true` if the sandbox is running, `false` if it is not, + * `undefined` if its state could not be determined. + */ +export type SandboxHealthCheck = () => Promise + +/** + * Message fragments different JS runtimes use when the connection to the sandbox + * is dropped mid-request. The transport surfaces a dropped connection (e.g. an + * HTTP/2 stream reset) with runtime- and version-specific wording, so we match + * every known variant: + * - Node (undici): `terminated` + * - Bun: `The socket connection was closed unexpectedly` + * - Deno: `error reading a body from connection` + */ +const CONNECTION_TERMINATED_MESSAGES = [ + 'terminated', + 'The socket connection was closed unexpectedly', + 'error reading a body from connection', +] + +/** + * Checks whether a message matches any known runtime variant of the connection to + * the sandbox being dropped mid-request (see {@link CONNECTION_TERMINATED_MESSAGES}). + */ +export function isConnectionTerminatedMessage( + message: string | undefined +): boolean { + if (!message) { + return false + } + + return CONNECTION_TERMINATED_MESSAGES.some((fragment) => + message.includes(fragment) + ) +} + +/** + * Checks whether the error is the signature of the connection to the sandbox being + * dropped mid-request — an HTTP/2 stream reset surfaced by connect as `Code.Unknown` + * with one of the runtime-specific connection-dropped messages. + */ +export function isConnectionTerminatedError(err: unknown): boolean { + return ( + err instanceof ConnectError && + err.code === Code.Unknown && + isConnectionTerminatedMessage(err.rawMessage) + ) +} + +const DEFAULT_ERROR_MAP: Partial Error>> = { + [Code.InvalidArgument]: (message) => new InvalidArgumentError(message), + [Code.Unauthenticated]: (message) => new AuthenticationError(message), + [Code.NotFound]: (message) => new NotFoundError(message), + [Code.ResourceExhausted]: (message) => + new RateLimitError( + `${message}: Rate limit exceeded, please try again later.` + ), + [Code.Unavailable]: formatSandboxTimeoutError, + [Code.Canceled]: (message) => + new TimeoutError( + `${message}: This error is likely due to exceeding 'requestTimeoutMs'. You can pass the request timeout value as an option when making the request.` + ), + [Code.DeadlineExceeded]: (message) => + new TimeoutError( + `${message}: This error is likely due to exceeding 'timeoutMs' — the total time a long running request (like command execution or directory watch) can be active. It can be modified by passing 'timeoutMs' when making the request. Use '0' to disable the timeout.` + ), +} + +/** + * Handles errors from envd RPC calls by mapping gRPC status codes to specific error types. + * + * @param err - The caught error, expected to be a `ConnectError` from the gRPC transport. + * @param errorMap - Optional map of gRPC `Code` values to error factory functions that override the defaults. + * @returns The corresponding `Error` instance mapped from the gRPC status code, or the original error if it is not a `ConnectError`. + */ +export function handleRpcError( + err: unknown, + errorMap?: Partial Error>> +): Error { + if (err instanceof ConnectError) { + // Check if a custom error mapping is provided for this error code + if (errorMap && err.code in errorMap) { + return errorMap[err.code]!(err.message) + } + + // Check if there is a default error mapping for this error code + if (err.code in DEFAULT_ERROR_MAP) { + return DEFAULT_ERROR_MAP[err.code]!(err.message) + } + + // Fallback to a generic SandboxError if no specific mapping is found + return new SandboxError(`${err.code}: ${err.message}`) + } + + return err as Error +} + +/** + * Like {@link handleRpcError}, but when the connection to the sandbox was dropped + * mid-request it probes the sandbox health to tell apart the sandbox being killed + * from a transient network failure (e.g. a load balancer dropping the connection). + * When the probe confirms the sandbox is gone, a `TimeoutError` is returned — + * consistent with how requests to an already-dead sandbox surface. + * + * @param err - The caught error, expected to be a `ConnectError` from the gRPC transport. + * @param checkHealth - Probe returning whether the sandbox is running, or `undefined` when unknown. + * @param errorMap - Optional map of gRPC `Code` values to error factory functions that override the defaults. + * @returns The corresponding `Error` instance. + */ +export async function handleRpcErrorWithHealthCheck( + err: unknown, + checkHealth?: SandboxHealthCheck, + errorMap?: Partial Error>> +): Promise { + if (isConnectionTerminatedError(err) && checkHealth) { + const running = await checkHealth().catch(() => undefined) + + if (running === false) { + return new TimeoutError( + `${(err as ConnectError).message}: The sandbox was killed or reached its end of life while the request was in flight.` + ) + } + } + + return handleRpcError(err, errorMap) +} + +function encode64(value: string): string { + switch (runtime) { + case 'deno': + return btoa(value) + case 'node': + return Buffer.from(value).toString('base64') + case 'bun': + return Buffer.from(value).toString('base64') + default: + return btoa(value) + } +} + +export function authenticationHeader( + envdVersion: string, + username: string | undefined +): Record { + if ( + username == undefined && + compareVersions(envdVersion, ENVD_DEFAULT_USER) < 0 + ) { + username = defaultUsername + } + + if (!username) { + return {} + } + + const value = `${username}:` + + const encoded = encode64(value) + + return { Authorization: `Basic ${encoded}` } +} diff --git a/packages/js-sdk/src/envd/schema.gen.ts b/packages/js-sdk/src/envd/schema.gen.ts new file mode 100644 index 0000000..a6b8725 --- /dev/null +++ b/packages/js-sdk/src/envd/schema.gen.ts @@ -0,0 +1,397 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/envs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the environment variables */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Environment variables */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EnvVars"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Download a file */ + get: { + parameters: { + query?: { + /** @description Path to the file, URL encoded. Can be relative to user's home directory. */ + path?: components["parameters"]["FilePath"]; + /** @description Signature used for file access permission verification. */ + signature?: components["parameters"]["Signature"]; + /** @description Signature expiration used for defining the expiration time of the signature. */ + signature_expiration?: components["parameters"]["SignatureExpiration"]; + /** @description User used for setting the owner, or resolving relative paths. */ + username?: components["parameters"]["User"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["DownloadSuccess"]; + 400: components["responses"]["InvalidPath"]; + 401: components["responses"]["InvalidUser"]; + 404: components["responses"]["FileNotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + put?: never; + /** + * Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. + * @description Any request header of the form `X-Metadata-: ` is persisted + * as a user-defined extended attribute on the uploaded file. The + * `X-Metadata-` prefix is stripped and the remaining header name is + * lowercased to form the metadata key; the resulting map is returned on + * `EntryInfo` lookups (e.g. `Stat`, `ListDir`). + * + * Each upload replaces the file's metadata with the keys provided in + * that request: keys previously stored but absent from the new request + * are removed, and an upload that sends no `X-Metadata-*` header clears + * all existing metadata. + * + * Both keys and values must be printable US-ASCII (bytes `0x20`-`0x7E`) + * and are rejected with HTTP 400 otherwise. Each key is capped at 246 + * bytes (the Linux VFS xattr-name limit minus the namespace prefix), and + * the combined size of all metadata on a file (keys plus values, with the + * namespace prefix counted per key) is capped at 4096 bytes to stay within + * the filesystem's per-inode xattr budget. Multiple files in a single + * multipart upload receive the same metadata. If the same + * `X-Metadata-` header is sent more than once, only the first + * value is used. + * + */ + post: { + parameters: { + query?: { + /** @description Path to the file, URL encoded. Can be relative to user's home directory. */ + path?: components["parameters"]["FilePath"]; + /** @description Signature used for file access permission verification. */ + signature?: components["parameters"]["Signature"]; + /** @description Signature expiration used for defining the expiration time of the signature. */ + signature_expiration?: components["parameters"]["SignatureExpiration"]; + /** @description User used for setting the owner, or resolving relative paths. */ + username?: components["parameters"]["User"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: components["requestBodies"]["File"]; + responses: { + 200: components["responses"]["UploadSuccess"]; + 400: components["responses"]["InvalidPath"]; + 401: components["responses"]["InvalidUser"]; + 500: components["responses"]["InternalServerError"]; + 507: components["responses"]["NotEnoughDiskSpace"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Check the health of the service */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The service is healthy */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/init": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Set initial vars, ensure the time and metadata is synced with the host */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Access token for secure access to envd service */ + accessToken?: string; + /** @description The default user to use for operations */ + defaultUser?: string; + /** @description The default working directory to use for operations */ + defaultWorkdir?: string; + envVars?: components["schemas"]["EnvVars"]; + /** @description IP address of the hyperloop server to connect to */ + hyperloopIP?: string; + /** + * Format: date-time + * @description The current timestamp in RFC3339 format + */ + timestamp?: string; + }; + }; + }; + responses: { + /** @description Env vars set, the time and metadata is synced with the host */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the stats of the service */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The resource usage metrics of the service */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Metrics"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + EntryInfo: { + /** @description User-defined metadata stored as extended attributes on the file. */ + metadata?: { + [key: string]: string; + }; + /** @description Name of the file */ + name: string; + /** @description Path to the file */ + path: string; + /** + * @description Type of the file + * @enum {string} + */ + type: "file"; + }; + /** @description Environment variables to set */ + EnvVars: { + [key: string]: string; + }; + Error: { + /** @description Error code */ + code: number; + /** @description Error message */ + message: string; + }; + /** @description Resource usage metrics */ + Metrics: { + /** @description Number of CPU cores */ + cpu_count?: number; + /** + * Format: float + * @description CPU usage percentage + */ + cpu_used_pct?: number; + /** @description Total disk space in bytes */ + disk_total?: number; + /** @description Used disk space in bytes */ + disk_used?: number; + /** @description Total virtual memory in bytes */ + mem_total?: number; + /** @description Used virtual memory in bytes */ + mem_used?: number; + /** + * Format: int64 + * @description Unix timestamp in UTC for current sandbox time + */ + ts?: number; + }; + }; + responses: { + /** @description Entire file downloaded successfully. */ + DownloadSuccess: { + headers: { + [name: string]: unknown; + }; + content: { + "application/octet-stream": string; + }; + }; + /** @description File not found */ + FileNotFound: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + InternalServerError: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Invalid path */ + InvalidPath: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Invalid user */ + InvalidUser: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not enough disk space */ + NotEnoughDiskSpace: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The file was uploaded successfully. */ + UploadSuccess: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EntryInfo"][]; + }; + }; + }; + parameters: { + /** @description Path to the file, URL encoded. Can be relative to user's home directory. */ + FilePath: string; + /** @description Signature used for file access permission verification. */ + Signature: string; + /** @description Signature expiration used for defining the expiration time of the signature. */ + SignatureExpiration: number; + /** @description User used for setting the owner, or resolving relative paths. */ + User: string; + }; + requestBodies: { + File: { + content: { + "multipart/form-data": { + /** Format: binary */ + file?: string; + }; + }; + }; + }; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/packages/js-sdk/src/envd/versions.ts b/packages/js-sdk/src/envd/versions.ts new file mode 100644 index 0000000..61434e6 --- /dev/null +++ b/packages/js-sdk/src/envd/versions.ts @@ -0,0 +1,9 @@ +export const ENVD_VERSION_RECURSIVE_WATCH = '0.1.4' +export const ENVD_DEBUG_FALLBACK = '99.99.99' +export const ENVD_COMMANDS_STDIN = '0.3.0' +export const ENVD_DEFAULT_USER = '0.4.0' +export const ENVD_ENVD_CLOSE = '0.5.2' +export const ENVD_OCTET_STREAM_UPLOAD = '0.5.7' +export const ENVD_FILE_METADATA = '0.6.2' +export const ENVD_VERSION_FS_EVENT_ENTRY_INFO = '0.6.3' +export const ENVD_VERSION_WATCH_NETWORK_MOUNTS = '0.6.4' diff --git a/packages/js-sdk/src/errors.ts b/packages/js-sdk/src/errors.ts new file mode 100644 index 0000000..e66ea07 --- /dev/null +++ b/packages/js-sdk/src/errors.ts @@ -0,0 +1,176 @@ +// This is the message for the sandbox timeout error when the response code is 502/Unavailable +export function formatSandboxTimeoutError(message: string) { + return new TimeoutError( + `${message}: This error is likely due to sandbox timeout. You can modify the sandbox timeout by passing 'timeoutMs' when starting the sandbox or calling '.setTimeout' on the sandbox with the desired timeout.` + ) +} + +/** + * Base class for all sandbox errors. + * + * Thrown when general sandbox errors occur. + */ +export class SandboxError extends Error { + constructor(message?: string, stackTrace?: string) { + super(message) + this.name = 'SandboxError' + if (stackTrace) { + this.stack = stackTrace + } + } +} + +/** + * Thrown when a timeout error occurs. + * + * The [unavailable] error type is caused by sandbox timeout. + * + * The [canceled] error type is caused by exceeding request timeout. + * + * The [deadline_exceeded] error type is caused by exceeding the timeout for command execution, watch, etc. + * + * The [unknown] error type is sometimes caused by the sandbox timeout when the request is not processed correctly. + */ +export class TimeoutError extends SandboxError { + constructor(message: string, stackTrace?: string) { + super(message, stackTrace) + this.name = 'TimeoutError' + } +} + +/** + * Thrown when an invalid argument is provided. + */ +export class InvalidArgumentError extends SandboxError { + constructor(message: string, stackTrace?: string) { + super(message, stackTrace) + this.name = 'InvalidArgumentError' + } +} + +/** + * Thrown when there is not enough disk space. + */ +export class NotEnoughSpaceError extends SandboxError { + constructor(message: string, stackTrace?: string) { + super(message, stackTrace) + this.name = 'NotEnoughSpaceError' + } +} + +/** + * Thrown when a resource is not found. + * + * @deprecated Use {@link FileNotFoundError} or {@link SandboxNotFoundError} instead. This class will be removed in the next major version. + */ +export class NotFoundError extends SandboxError { + constructor(message: string, stackTrace?: string) { + super(message, stackTrace) + this.name = 'NotFoundError' + } +} + +/** + * Thrown when a file or directory is not found inside a sandbox. + */ +export class FileNotFoundError extends NotFoundError { + constructor(message: string, stackTrace?: string) { + super(message, stackTrace) + this.name = 'FileNotFoundError' + } +} + +/** + * Thrown when a sandbox is not found (e.g. it doesn't exist or is no longer running). + */ +export class SandboxNotFoundError extends NotFoundError { + constructor(message: string, stackTrace?: string) { + super(message, stackTrace) + this.name = 'SandboxNotFoundError' + } +} + +/** + * Thrown when authentication fails. + */ +export class AuthenticationError extends Error { + constructor(message: string) { + super(message) + this.name = 'AuthenticationError' + } +} + +/** + * Thrown when git authentication fails. + */ +export class GitAuthError extends AuthenticationError { + constructor(message: string) { + super(message) + this.name = 'GitAuthError' + } +} + +/** + * Thrown when git upstream tracking is missing. + */ +export class GitUpstreamError extends SandboxError { + constructor(message: string, stackTrace?: string) { + super(message, stackTrace) + this.name = 'GitUpstreamError' + } +} + +/** + * Thrown when the template uses old envd version. It isn't compatible with the new SDK. + */ +export class TemplateError extends SandboxError { + constructor(message: string, stackTrace?: string) { + super(message, stackTrace) + this.name = 'TemplateError' + } +} + +/** + * Thrown when the API rate limit is exceeded. + */ +export class RateLimitError extends SandboxError { + constructor(message: string) { + super(message) + this.name = 'RateLimitError' + } +} + +/** + * Thrown when the build fails. + */ +export class BuildError extends Error { + constructor(message: string, stackTrace?: string) { + super(message) + this.name = 'BuildError' + if (stackTrace) { + this.stack = stackTrace + } + } +} + +/** + * Thrown when the file upload fails. + */ +export class FileUploadError extends BuildError { + constructor(message: string, stackTrace?: string) { + super(message, stackTrace) + this.name = 'FileUploadError' + } +} + +/** + * Base class for all volume errors. + * + * Thrown when general volume errors occur. + */ +export class VolumeError extends Error { + constructor(message: string) { + super(message) + this.name = 'VolumeError' + } +} diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts new file mode 100644 index 0000000..e20436e --- /dev/null +++ b/packages/js-sdk/src/index.ts @@ -0,0 +1,151 @@ +export { ApiClient } from './api' +export type { components, paths } from './api' + +export { ConnectionConfig } from './connectionConfig' +export type { + ConnectionConfigOpts, + ConnectionOpts, + Username, +} from './connectionConfig' +export { + AuthenticationError, + FileNotFoundError, + GitAuthError, + GitUpstreamError, + InvalidArgumentError, + NotEnoughSpaceError, + NotFoundError, + SandboxError, + SandboxNotFoundError, + TemplateError, + TimeoutError, + RateLimitError, + BuildError, + FileUploadError, + VolumeError, +} from './errors' +export type { Logger } from './logs' + +export { getSignature } from './sandbox/signature' + +export { FileType } from './sandbox/filesystem' +export type { + WriteInfo, + EntryInfo, + Filesystem, + FilesystemWriteOpts, + FilesystemReadOpts, +} from './sandbox/filesystem' +export { FilesystemEventType } from './sandbox/filesystem/watchHandle' +export type { + FilesystemEvent, + WatchHandle, +} from './sandbox/filesystem/watchHandle' + +export { CommandExitError } from './sandbox/commands/commandHandle' +export type { + CommandResult, + Stdout, + Stderr, + PtyOutput, + CommandHandle, +} from './sandbox/commands/commandHandle' +export type { + SandboxInfo, + SandboxMetrics, + SandboxOpts, + SandboxApiOpts, + SandboxConnectOpts, + SandboxMetricsOpts, + SandboxPauseOpts, + SandboxState, + SandboxListOpts, + SandboxPaginator, + SandboxNetworkOpts, + SandboxNetworkInfo, + SandboxNetworkSelector, + SandboxNetworkSelectorContext, + SandboxNetworkRule, + SandboxNetworkRuleInfo, + SandboxNetworkRules, + SandboxNetworkTransform, + SandboxNetworkUpdate, + SandboxOnTimeout, + SandboxLifecycle, + SandboxInfoLifecycle, + SnapshotInfo, + SnapshotListOpts, + SnapshotPaginator, + CreateSnapshotOpts, +} from './sandbox/sandboxApi' + +export type { McpServer } from './sandbox/mcp' + +export { ALL_TRAFFIC } from './sandbox/network' + +export type { + ProcessInfo, + CommandRequestOpts, + CommandConnectOpts, + CommandStartOpts, + Commands, + Pty, +} from './sandbox/commands' + +export { Git } from './sandbox/git' +export type { + GitRequestOpts, + GitCloneOpts, + GitInitOpts, + GitRemoteAddOpts, + GitCommitOpts, + GitAddOpts, + GitDeleteBranchOpts, + GitPushOpts, + GitPullOpts, + GitDangerouslyAuthenticateOpts, + GitConfigOpts, + GitConfigScope, + GitBranches, + GitFileStatus, + GitStatus, +} from './sandbox/git' + +export { Volume, VolumeFileType } from './volume' +export type { + VolumeInfo, + VolumeAndToken, + VolumeEntryStat, + VolumeMetadataOpts, + VolumeReadOpts, + VolumeWriteOpts, + VolumeApiOpts, + VolumeConnectionConfig, + // Deprecated aliases, kept for backwards compatibility. + VolumeMetadataOptions, + VolumeWriteOptions, +} from './volume' + +export { Sandbox } +import { Sandbox } from './sandbox' + +export default Sandbox + +export * from './template' + +export { + ReadyCmd, + waitForPort, + waitForURL, + waitForProcess, + waitForFile, + waitForTimeout, +} from './template/readycmd' + +export { + LogEntry, + LogEntryStart, + LogEntryEnd, + type LogEntryLevel, + defaultBuildLogger, +} from './template/logger' diff --git a/packages/js-sdk/src/logs.ts b/packages/js-sdk/src/logs.ts new file mode 100644 index 0000000..d3ed59f --- /dev/null +++ b/packages/js-sdk/src/logs.ts @@ -0,0 +1,77 @@ +import type { Interceptor } from '@connectrpc/connect' +import type { Middleware } from 'openapi-fetch' + +/** + * Logger interface compatible with {@link console} used for logging Sandbox messages. + */ +export interface Logger { + /** + * Debug level logging method. + */ + debug?: (...args: any[]) => void + /** + * Info level logging method. + */ + info?: (...args: any[]) => void + /** + * Warn level logging method. + */ + warn?: (...args: any[]) => void + /** + * Error level logging method. + */ + error?: (...args: any[]) => void +} + +function formatLog(log: any) { + // Protobuf int64 fields are represented as bigint, which JSON.stringify + // can't serialize without a replacer. + return JSON.parse( + JSON.stringify(log, (_, value) => + typeof value === 'bigint' ? value.toString() : value + ) + ) +} + +export function createRpcLogger(logger: Logger): Interceptor { + async function* logEach(stream: AsyncIterable) { + for await (const m of stream) { + logger.debug?.('Response stream:', formatLog(m)) + yield m + } + } + + return (next) => async (req) => { + logger.info?.(`Request: POST ${req.url}`) + + const res = await next(req) + if (res.stream) { + return { + ...res, + message: logEach(res.message), + } + } else { + logger.info?.('Response:', formatLog(res.message)) + } + + return res + } +} + +export function createApiLogger(logger: Logger): Middleware { + return { + async onRequest({ request }) { + logger.info?.(`Request ${request.method} ${request.url}`) + return request + }, + async onResponse({ response }) { + if (response.status >= 400) { + logger.error?.('Response:', response.status, response.statusText) + } else { + logger.info?.('Response:', response.status, response.statusText) + } + + return response + }, + } +} diff --git a/packages/js-sdk/src/paginator.ts b/packages/js-sdk/src/paginator.ts new file mode 100644 index 0000000..051655a --- /dev/null +++ b/packages/js-sdk/src/paginator.ts @@ -0,0 +1,77 @@ +import type { ConnectionOpts } from './connectionConfig' + +/** + * Generic, reusable paginator for cursor-based list endpoints. + * + * The base owns the shared pagination state — `hasNext`, `nextToken`, and the + * reading of the `x-next-token` response header (via {@link Paginator.updatePagination}). + * Each concrete paginator implements {@link Paginator.nextItems} to do the + * actual fetching for its endpoint, so any model can expose pagination by + * subclassing this without reimplementing the bookkeeping. + * + * The optional `O` type parameter is the per-call options type accepted by + * `nextItems` (e.g. connection options for a given API). + * + * @example + * ```ts + * const paginator = Sandbox.list() + * while (paginator.hasNext) { + * const items = await paginator.nextItems() + * console.log(items) + * } + * ``` + */ +export abstract class Paginator { + protected readonly opts?: O + protected readonly limit?: number + + private _hasNext: boolean + private _nextToken?: string + + constructor(opts?: O, limit?: number, nextToken?: string) { + this.opts = opts + this.limit = limit + + this._hasNext = true + this._nextToken = nextToken + } + + /** + * Returns true if there are more items to fetch. + */ + get hasNext(): boolean { + return this._hasNext + } + + /** + * Returns the next token to use for pagination. + */ + get nextToken(): string | undefined { + return this._nextToken + } + + /** + * Update the pagination state from a response, reading the `x-next-token` + * header. Concrete paginators call this from {@link Paginator.nextItems} + * after fetching a page. + */ + protected updatePagination(response: Response) { + this._nextToken = response.headers.get('x-next-token') || undefined + this._hasNext = !!this._nextToken + } + + /** + * Get the next page of items. + * + * @param opts per-call connection options. When provided, this call uses + * these options (e.g. `apiKey`, `domain`, `headers`, `requestTimeoutMs`, + * `signal`) instead of the ones the paginator was constructed with. + * Aborting a page via `signal` does not affect subsequent {@link Paginator.nextItems} + * calls — pass a fresh signal each call you want to be cancellable. + * + * @throws Error if there are no more items to fetch. Call this method only if `hasNext` is `true`. + * + * @returns List of items + */ + abstract nextItems(opts?: O): Promise +} diff --git a/packages/js-sdk/src/sandbox/commands/commandHandle.ts b/packages/js-sdk/src/sandbox/commands/commandHandle.ts new file mode 100644 index 0000000..759d17f --- /dev/null +++ b/packages/js-sdk/src/sandbox/commands/commandHandle.ts @@ -0,0 +1,375 @@ +import { + handleRpcErrorWithHealthCheck, + SandboxHealthCheck, +} from '../../envd/rpc' +import { SandboxError } from '../../errors' +import { ConnectResponse, StartResponse } from '../../envd/process/process_pb' +import type { CommandRequestOpts } from '.' + +declare const __brand: unique symbol +type Brand = { [__brand]: B } +export type Branded = T & Brand + +export type Stdout = Branded +export type Stderr = Branded +export type PtyOutput = Branded + +/** + * Command execution result. + */ +export interface CommandResult { + /** + * Command execution exit code. + * `0` if the command finished successfully. + */ + exitCode: number + /** + * Error message from command execution if it failed. + */ + error?: string + /** + * Command stdout output. + */ + stdout: string + /** + * Command stderr output. + */ + stderr: string +} + +/** + * Error thrown when a command exits with a non-zero exit code. + */ +export class CommandExitError extends SandboxError implements CommandResult { + constructor(private readonly result: CommandResult) { + super(result.error) + this.name = 'CommandExitError' + } + + /** + * Command execution exit code. + * `0` if the command finished successfully. + */ + get exitCode() { + return this.result.exitCode + } + + /** + * Error message from command execution. + */ + get error() { + return this.result.error + } + + /** + * Command execution stdout output. + */ + get stdout() { + return this.result.stdout + } + + /** + * Command execution stderr output. + */ + get stderr() { + return this.result.stderr + } +} + +/** + * Command execution handle. + * + * It provides methods for waiting for the command to finish, retrieving stdout/stderr, and killing the command. + * + * @property {number} pid process ID of the command. + */ +export class CommandHandle + implements + Omit, + Partial> +{ + private _stdout = '' + private _stderr = '' + + private readonly stdoutDecoder = new TextDecoder() + private readonly stderrDecoder = new TextDecoder() + + private result?: CommandResult + private iterationError?: Error + + private disconnected = false + + private readonly _wait: Promise + + /** + * @hidden + * @internal + * @access protected + */ + constructor( + readonly pid: number, + private readonly handleDisconnect: () => void, + private readonly handleKill: () => Promise, + private readonly events: AsyncIterable, + private readonly onStdout?: (stdout: string) => void | Promise, + private readonly onStderr?: (stderr: string) => void | Promise, + private readonly onPty?: (pty: Uint8Array) => void | Promise, + private readonly handleSendStdin?: ( + data: string | Uint8Array, + opts?: CommandRequestOpts + ) => Promise, + private readonly handleCloseStdin?: ( + opts?: CommandRequestOpts + ) => Promise, + private readonly checkHealth?: SandboxHealthCheck + ) { + this._wait = this.handleEvents() + } + + /** + * Command execution exit code. + * `0` if the command finished successfully. + * + * It is `undefined` if the command is still running. + */ + get exitCode() { + return this.result?.exitCode + } + + /** + * Error message from command execution. + */ + get error() { + return this.result?.error + } + + /** + * Command execution stderr output. + */ + get stderr() { + return this._stderr + } + + /** + * Command execution stdout output. + */ + get stdout() { + return this._stdout + } + + /** + * Wait for the command to finish and return the result. + * If the command exits with a non-zero exit code, it throws a `CommandExitError`. + * + * @returns `CommandResult` result of command execution. + */ + async wait() { + await this._wait + + if (this.iterationError) { + throw this.iterationError + } + + if (!this.result) { + throw new SandboxError('Process exited without a result') + } + + if (this.result.exitCode !== 0) { + throw new CommandExitError(this.result) + } + + return this.result + } + + /** + * Disconnect from the command. + * + * The command is not killed, but SDK stops receiving events from the command. + * You can reconnect to the command using {@link Commands.connect}. + * + * Once it returns, the `onStdout`/`onStderr`/`onPty` callbacks are guaranteed + * not to fire for output produced after this call. It does not wait for the + * event handler to drain, so it returns promptly even for an idle command + * whose stream produces no further output. + */ + async disconnect() { + this.disconnected = true + this.handleDisconnect() + } + + /** + * Kill the command. + * It uses `SIGKILL` signal to kill the command. + * + * @returns `true` if the command was killed successfully, `false` if the command was not found. + */ + async kill() { + return await this.handleKill() + } + + /** + * Send data to the command stdin. + * + * The command must have been started with `stdin: true`. + * + * @param data data to send to the command. + * @param opts connection options. + */ + async sendStdin( + data: string | Uint8Array, + opts?: CommandRequestOpts + ): Promise { + if (!this.handleSendStdin) { + throw new SandboxError( + 'Sending stdin is not supported for this command handle.' + ) + } + + await this.handleSendStdin(data, opts) + } + + /** + * Close the command stdin. + * + * This signals EOF to the command. The command must have been started with + * `stdin: true`. + * + * @param opts connection options. + */ + async closeStdin(opts?: CommandRequestOpts): Promise { + if (!this.handleCloseStdin) { + throw new SandboxError( + 'Closing stdin is not supported for this command handle.' + ) + } + + await this.handleCloseStdin(opts) + } + + /** + * Flush any bytes still buffered in the stream decoders. + * + * Incomplete trailing UTF-8 sequences are emitted as replacement + * characters, matching the per-chunk decoding behavior. + */ + private *flushDecoders(): Generator< + [Stdout, null, null] | [null, Stderr, null] + > { + const stdoutRest = this.stdoutDecoder.decode() + if (stdoutRest) { + this._stdout += stdoutRest + yield [stdoutRest as Stdout, null, null] + } + const stderrRest = this.stderrDecoder.decode() + if (stderrRest) { + this._stderr += stderrRest + yield [null, stderrRest as Stderr, null] + } + } + + private async *iterateEvents(): AsyncGenerator< + [Stdout, null, null] | [null, Stderr, null] | [null, null, PtyOutput] + > { + try { + for await (const event of this.events) { + const e = event?.event?.event + let out: string | undefined + + switch (e?.case) { + case 'data': + switch (e.value.output.case) { + case 'stdout': + out = this.stdoutDecoder.decode(e.value.output.value, { + stream: true, + }) + if (out) { + this._stdout += out + yield [out as Stdout, null, null] + } + break + case 'stderr': + out = this.stderrDecoder.decode(e.value.output.value, { + stream: true, + }) + if (out) { + this._stderr += out + yield [null, out as Stderr, null] + } + break + case 'pty': + yield [null, null, e.value.output.value as PtyOutput] + break + } + break + case 'end': { + // Flush trailing decoder bytes into the accumulators and record the + // result *before* yielding the flushed chunks. A disconnected + // consumer breaks out of `handleEvents` on the first yielded chunk, + // which would otherwise abort this generator before `this.result` + // is assigned and make `wait()` fail as if the process never + // produced a result. + const flushed = [...this.flushDecoders()] + this.result = { + exitCode: e.value.exitCode, + error: e.value.error, + stdout: this.stdout, + stderr: this.stderr, + } + for (const chunk of flushed) { + yield chunk + } + break + } + } + // TODO: Handle empty events like in python SDK + } + } catch (e) { + // The stream raised before an `end` event (e.g. disconnect or RPC + // failure). Flush any bytes still buffered in the decoders so incomplete + // trailing sequences surface as replacement characters instead of being + // silently dropped, then re-raise so the error is still surfaced. + yield* this.flushDecoders() + throw e + } + + // If the stream closed without an `end` event (e.g. disconnect or a + // dropped connection), flush any bytes still buffered in the decoders so + // incomplete trailing sequences surface as replacement characters instead + // of being silently dropped. + if (this.result === undefined) { + yield* this.flushDecoders() + } + } + + private async handleEvents() { + try { + for await (const [stdout, stderr, pty] of this.iterateEvents()) { + // The handle was disconnected — stop dispatching to the callbacks. The + // flag is checked before every dispatch, so no callback fires for + // output that arrives (or was buffered) after disconnect() was called, + // even if the underlying abort hasn't torn the stream down yet. There + // is no synchronous suspension point between this check and the + // dispatch below, so disconnect() cannot interleave to let a late event + // slip through. + if (this.disconnected) { + break + } + + if (stdout !== null) { + await this.onStdout?.(stdout) + } else if (stderr !== null) { + await this.onStderr?.(stderr) + } else if (pty) { + await this.onPty?.(pty) + } + } + } catch (e) { + this.iterationError = await handleRpcErrorWithHealthCheck( + e, + this.checkHealth + ) + } finally { + this.handleDisconnect() + } + } +} diff --git a/packages/js-sdk/src/sandbox/commands/index.ts b/packages/js-sdk/src/sandbox/commands/index.ts new file mode 100644 index 0000000..bbc9224 --- /dev/null +++ b/packages/js-sdk/src/sandbox/commands/index.ts @@ -0,0 +1,488 @@ +import { + Client, + Code, + ConnectError, + Transport, + createClient, +} from '@connectrpc/connect' + +import { compareVersions } from 'compare-versions' +import { + ConnectionConfig, + ConnectionOpts, + KEEPALIVE_PING_HEADER, + KEEPALIVE_PING_INTERVAL_SEC, + setupRequestController, + Username, +} from '../../connectionConfig' +import { + checkSandboxHealth, + EnvdApiClient, + handleProcessStartEvent, +} from '../../envd/api' +import { + Process as ProcessService, + Signal, +} from '../../envd/process/process_pb' +import { + authenticationHeader, + handleRpcErrorWithHealthCheck, + SandboxHealthCheck, +} from '../../envd/rpc' +import { ENVD_COMMANDS_STDIN, ENVD_ENVD_CLOSE } from '../../envd/versions' +import { SandboxError } from '../../errors' +import { CommandHandle, CommandResult } from './commandHandle' +export { Pty } from './pty' + +/** + * Options for sending a command request. + */ +export interface CommandRequestOpts + extends Partial> {} + +/** + * Options for starting a new command. + */ +export interface CommandStartOpts extends CommandRequestOpts { + /** + * If true, starts command in the background and the method returns immediately. + * You can use {@link CommandHandle.wait} to wait for the command to finish. + */ + background?: boolean + /** + * Working directory for the command. + * + * @default // home directory of the user used to start the command + */ + cwd?: string + /** + * User to run the command as. + * + * @default `default Sandbox user (as specified in the template)` + */ + user?: Username + /** + * Environment variables used for the command. + * + * This overrides the default environment variables from `Sandbox` constructor. + * + * @default `{}` + */ + envs?: Record + /** + * Callback for command stdout output. + */ + onStdout?: (data: string) => void | Promise + /** + * Callback for command stderr output. + */ + onStderr?: (data: string) => void | Promise + /** + * If true, command stdin is kept open and you can send data to it using {@link Commands.sendStdin} or {@link CommandHandle.sendStdin}. + * @default false + */ + stdin?: boolean + /** + * Timeout for the command in **milliseconds**. + * + * @default 60_000 // 60 seconds + */ + timeoutMs?: number +} + +/** + * Options for connecting to a command. + */ +export type CommandConnectOpts = Pick< + CommandStartOpts, + 'onStderr' | 'onStdout' | 'timeoutMs' +> & + CommandRequestOpts + +/** + * Information about a command, PTY session or start command running in the sandbox as process. + */ +export interface ProcessInfo { + /** + * Process ID. + */ + pid: number + /** + * Custom tag used for identifying special commands like start command in the custom template. + */ + tag?: string + /** + * Command that was executed. + */ + cmd: string + /** + * Command arguments. + */ + args: string[] + /** + * Environment variables used for the command. + */ + envs: Record + /** + * Executed command working directory. + */ + cwd?: string +} + +/** + * Module for starting and interacting with commands in the sandbox. + */ +export class Commands { + protected readonly rpc: Client + + private readonly defaultProcessConnectionTimeout = 60_000 // 60 seconds + private readonly envdVersion: string + private readonly checkHealth: SandboxHealthCheck + + constructor( + transport: Transport, + private readonly envdApi: EnvdApiClient, + private readonly connectionConfig: ConnectionConfig + ) { + this.rpc = createClient(ProcessService, transport) + this.envdVersion = envdApi.version + this.checkHealth = () => checkSandboxHealth(this.envdApi) + } + + /** + * @hidden + * @internal + */ + get supportsStdinClose(): boolean { + return compareVersions(this.envdVersion, ENVD_ENVD_CLOSE) >= 0 + } + + /** + * List all running commands and PTY sessions. + * + * @param opts connection options. + * + * @returns list of running commands and PTY sessions. + */ + async list(opts?: CommandRequestOpts): Promise { + try { + const res = await this.rpc.list( + {}, + { + signal: this.connectionConfig.getSignal( + opts?.requestTimeoutMs, + opts?.signal + ), + } + ) + + return res.processes.map((p) => ({ + pid: p.pid, + ...(p.tag && { tag: p.tag }), + args: p.config!.args, + envs: p.config!.envs, + cmd: p.config!.cmd, + ...(p.config!.cwd && { cwd: p.config!.cwd }), + })) + } catch (err) { + throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) + } + } + + /** + * Send data to command stdin. + * + * @param pid process ID of the command. You can get the list of running commands using {@link Commands.list}. + * @param data data to send to the command. + * @param opts connection options. + */ + async sendStdin( + pid: number, + data: string | Uint8Array, + opts?: CommandRequestOpts + ): Promise { + try { + const payload = + typeof data === 'string' ? new TextEncoder().encode(data) : data + + await this.rpc.sendInput( + { + process: { + selector: { + case: 'pid', + value: pid, + }, + }, + input: { + input: { + case: 'stdin', + value: payload, + }, + }, + }, + { + signal: this.connectionConfig.getSignal( + opts?.requestTimeoutMs, + opts?.signal + ), + } + ) + } catch (err) { + throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) + } + } + + /** + * Close command stdin. + * + * This signals EOF to the command. The command must have been started with `stdin: true`. + * + * @param pid process ID of the command. You can get the list of running commands using {@link Commands.list}. + * @param opts connection options. + */ + async closeStdin(pid: number, opts?: CommandRequestOpts): Promise { + if (!this.supportsStdinClose) { + throw new SandboxError( + `Sandbox envd version ${this.envdVersion} doesn't support closeStdin. Please rebuild your template to pick up the latest sandbox version.` + ) + } + + try { + await this.rpc.closeStdin( + { + process: { + selector: { + case: 'pid', + value: pid, + }, + }, + }, + { + signal: this.connectionConfig.getSignal( + opts?.requestTimeoutMs, + opts?.signal + ), + } + ) + } catch (err) { + throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) + } + } + + /** + * Kill a running command specified by its process ID. + * It uses `SIGKILL` signal to kill the command. + * + * @param pid process ID of the command. You can get the list of running commands using {@link Commands.list}. + * @param opts connection options. + * + * @returns `true` if the command was killed, `false` if the command was not found. + */ + async kill(pid: number, opts?: CommandRequestOpts): Promise { + try { + await this.rpc.sendSignal( + { + process: { + selector: { + case: 'pid', + value: pid, + }, + }, + signal: Signal.SIGKILL, + }, + { + signal: this.connectionConfig.getSignal( + opts?.requestTimeoutMs, + opts?.signal + ), + } + ) + + return true + } catch (err) { + if (err instanceof ConnectError) { + if (err.code === Code.NotFound) { + return false + } + } + + throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) + } + } + + /** + * Connect to a running command. + * You can use {@link CommandHandle.wait} to wait for the command to finish and get execution results. + * + * @param pid process ID of the command to connect to. You can get the list of running commands using {@link Commands.list}. + * @param opts connection options. + * + * @returns `CommandHandle` handle to interact with the running command. + */ + async connect( + pid: number, + opts?: CommandConnectOpts + ): Promise { + const requestTimeoutMs = + opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs + + const { controller, clearStartTimeout, cleanup } = setupRequestController( + requestTimeoutMs, + opts?.signal + ) + + const events = this.rpc.connect( + { + process: { + selector: { + case: 'pid', + value: pid, + }, + }, + }, + { + signal: controller.signal, + headers: { + [KEEPALIVE_PING_HEADER]: KEEPALIVE_PING_INTERVAL_SEC.toString(), + }, + timeoutMs: opts?.timeoutMs ?? this.defaultProcessConnectionTimeout, + } + ) + + try { + const pid = await handleProcessStartEvent(events) + clearStartTimeout() + + return new CommandHandle( + pid, + cleanup, + () => this.kill(pid), + events, + opts?.onStdout, + opts?.onStderr, + undefined, + (data, stdinOpts) => this.sendStdin(pid, data, stdinOpts), + (stdinOpts) => this.closeStdin(pid, stdinOpts), + this.checkHealth + ) + } catch (err) { + cleanup() + throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) + } + } + + /** + * Start a new command and wait until it finishes executing. + * + * @param cmd command to execute. + * @param opts options for starting the command. + * + * @returns `CommandResult` result of the command execution. + */ + async run( + cmd: string, + opts?: CommandStartOpts & { background?: false } + ): Promise + + /** + * Start a new command in the background. + * You can use {@link CommandHandle.wait} to wait for the command to finish and get its result. + * + * @param cmd command to execute. + * @param opts options for starting the command + * + * @returns `CommandHandle` handle to interact with the running command. + */ + async run( + cmd: string, + opts: CommandStartOpts & { background: true } + ): Promise + + // NOTE - The following overload seems redundant, but it's required to make the type inference work correctly. + + /** + * Start a new command. + * + * @param cmd command to execute. + * @param opts options for starting the command. + * - `opts.background: true` - runs in background, returns `CommandHandle` + * - `opts.background: false | undefined` - waits for completion, returns `CommandResult` + * + * @returns Either a `CommandHandle` or a `CommandResult` (depending on `opts.background`). + */ + async run( + cmd: string, + opts?: CommandStartOpts & { background?: boolean } + ): Promise + async run( + cmd: string, + opts?: CommandStartOpts & { background?: boolean } + ): Promise { + const proc = await this.start(cmd, opts) + + return opts?.background ? proc : proc.wait() + } + + private async start( + cmd: string, + opts?: CommandStartOpts + ): Promise { + if ( + opts?.stdin === false && + compareVersions(this.envdVersion, ENVD_COMMANDS_STDIN) < 0 + ) { + throw new SandboxError( + `Sandbox envd version ${this.envdVersion} can't specify stdin, it's always turned on. Please rebuild your template if you need this feature.` + ) + } + + const requestTimeoutMs = + opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs + + const { controller, clearStartTimeout, cleanup } = setupRequestController( + requestTimeoutMs, + opts?.signal + ) + + const events = this.rpc.start( + { + process: { + cmd: '/bin/bash', + cwd: opts?.cwd, + envs: opts?.envs, + args: ['-l', '-c', cmd], + }, + stdin: opts?.stdin || false, + }, + { + headers: { + ...authenticationHeader(this.envdVersion, opts?.user), + [KEEPALIVE_PING_HEADER]: KEEPALIVE_PING_INTERVAL_SEC.toString(), + }, + signal: controller.signal, + timeoutMs: opts?.timeoutMs ?? this.defaultProcessConnectionTimeout, + } + ) + + try { + const pid = await handleProcessStartEvent(events) + clearStartTimeout() + + return new CommandHandle( + pid, + cleanup, + () => this.kill(pid), + events, + opts?.onStdout, + opts?.onStderr, + undefined, + (data, stdinOpts) => this.sendStdin(pid, data, stdinOpts), + (stdinOpts) => this.closeStdin(pid, stdinOpts), + this.checkHealth + ) + } catch (err) { + cleanup() + throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) + } + } +} diff --git a/packages/js-sdk/src/sandbox/commands/pty.ts b/packages/js-sdk/src/sandbox/commands/pty.ts new file mode 100644 index 0000000..0fb685a --- /dev/null +++ b/packages/js-sdk/src/sandbox/commands/pty.ts @@ -0,0 +1,347 @@ +import { + Code, + ConnectError, + createClient, + Client, + Transport, +} from '@connectrpc/connect' + +import { + Signal, + Process as ProcessService, +} from '../../envd/process/process_pb' +import { + ConnectionConfig, + ConnectionOpts, + Username, + KEEPALIVE_PING_HEADER, + KEEPALIVE_PING_INTERVAL_SEC, + setupRequestController, +} from '../../connectionConfig' +import { CommandHandle } from './commandHandle' +import { + authenticationHeader, + handleRpcErrorWithHealthCheck, + SandboxHealthCheck, +} from '../../envd/rpc' +import { + checkSandboxHealth, + EnvdApiClient, + handleProcessStartEvent, +} from '../../envd/api' + +export interface PtyCreateOpts + extends Pick { + /** + * Number of columns for the PTY. + */ + cols: number + /** + * Number of rows for the PTY. + */ + rows: number + /** + * Callback to handle PTY data. + */ + onData: (data: Uint8Array) => void | Promise + /** + * Timeout for the PTY in **milliseconds**. + * + * @default 60_000 // 60 seconds + */ + timeoutMs?: number + /** + * User to use for the PTY. + * + * @default `default Sandbox user (as specified in the template)` + */ + user?: Username + /** + * Environment variables for the PTY. + * + * @default {} + */ + envs?: Record + /** + * Working directory for the PTY. + * + * @default // home directory of the user used to start the PTY + */ + cwd?: string +} + +/** + * Options for connecting to a command. + */ +export type PtyConnectOpts = Pick & + Pick + +/** + * Module for interacting with PTYs (pseudo-terminals) in the sandbox. + */ +export class Pty { + private readonly rpc: Client + private readonly envdVersion: string + private readonly checkHealth: SandboxHealthCheck + + private readonly defaultPtyConnectionTimeout = 60_000 // 60 seconds + + constructor( + private readonly transport: Transport, + private readonly envdApi: EnvdApiClient, + private readonly connectionConfig: ConnectionConfig + ) { + this.rpc = createClient(ProcessService, this.transport) + this.envdVersion = envdApi.version + this.checkHealth = () => checkSandboxHealth(this.envdApi) + } + + /** + * Create a new PTY (pseudo-terminal). + * + * @param opts options for creating the PTY. + * + * @returns handle to interact with the PTY. + */ + async create(opts: PtyCreateOpts) { + const requestTimeoutMs = + opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs + const envs = { ...(opts?.envs ?? {}) } + envs.TERM = envs.TERM ?? 'xterm-256color' + envs.LANG = envs.LANG ?? 'C.UTF-8' + envs.LC_ALL = envs.LC_ALL ?? 'C.UTF-8' + + const { controller, clearStartTimeout, cleanup } = setupRequestController( + requestTimeoutMs, + opts?.signal + ) + + const events = this.rpc.start( + { + process: { + cmd: '/bin/bash', + args: ['-i', '-l'], + envs: envs, + cwd: opts?.cwd, + }, + pty: { + size: { + cols: opts.cols, + rows: opts.rows, + }, + }, + }, + { + headers: { + ...authenticationHeader(this.envdVersion, opts?.user), + [KEEPALIVE_PING_HEADER]: KEEPALIVE_PING_INTERVAL_SEC.toString(), + }, + signal: controller.signal, + timeoutMs: opts?.timeoutMs ?? this.defaultPtyConnectionTimeout, + } + ) + + try { + const pid = await handleProcessStartEvent(events) + clearStartTimeout() + + return new CommandHandle( + pid, + cleanup, + () => this.kill(pid), + events, + undefined, + undefined, + opts.onData, + undefined, + undefined, + this.checkHealth + ) + } catch (err) { + cleanup() + throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) + } + } + + /** + * Connect to a running PTY. + * + * @param pid process ID of the PTY to connect to. You can get the list of running PTYs using {@link Commands.list}. + * @param opts connection options. + * + * @returns handle to interact with the PTY. + */ + async connect(pid: number, opts?: PtyConnectOpts): Promise { + const requestTimeoutMs = + opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs + + const { controller, clearStartTimeout, cleanup } = setupRequestController( + requestTimeoutMs, + opts?.signal + ) + + const events = this.rpc.connect( + { + process: { + selector: { + case: 'pid', + value: pid, + }, + }, + }, + { + signal: controller.signal, + headers: { + [KEEPALIVE_PING_HEADER]: KEEPALIVE_PING_INTERVAL_SEC.toString(), + }, + timeoutMs: opts?.timeoutMs ?? this.defaultPtyConnectionTimeout, + } + ) + + try { + const pid = await handleProcessStartEvent(events) + clearStartTimeout() + + return new CommandHandle( + pid, + cleanup, + () => this.kill(pid), + events, + undefined, + undefined, + opts?.onData, + undefined, + undefined, + this.checkHealth + ) + } catch (err) { + cleanup() + throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) + } + } + + /** + * Send input to a PTY. + * + * @param pid process ID of the PTY. + * @param data input data to send to the PTY. + * @param opts connection options. + */ + async sendInput( + pid: number, + data: Uint8Array, + opts?: Pick + ): Promise { + try { + await this.rpc.sendInput( + { + input: { + input: { + case: 'pty', + value: data, + }, + }, + process: { + selector: { + case: 'pid', + value: pid, + }, + }, + }, + { + signal: this.connectionConfig.getSignal( + opts?.requestTimeoutMs, + opts?.signal + ), + } + ) + } catch (err) { + throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) + } + } + + /** + * Resize PTY. + * Call this when the terminal window is resized and the number of columns and rows has changed. + * + * @param pid process ID of the PTY. + * @param size new size of the PTY. + * @param opts connection options. + */ + async resize( + pid: number, + size: { + cols: number + rows: number + }, + opts?: Pick + ): Promise { + try { + await this.rpc.update( + { + process: { + selector: { + case: 'pid', + value: pid, + }, + }, + pty: { + size, + }, + }, + { + signal: this.connectionConfig.getSignal( + opts?.requestTimeoutMs, + opts?.signal + ), + } + ) + } catch (err) { + throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) + } + } + + /** + * Kill a running PTY specified by process ID. + * It uses `SIGKILL` signal to kill the PTY. + * + * @param pid process ID of the PTY. + * @param opts connection options. + * + * @returns `true` if the PTY was killed, `false` if the PTY was not found. + */ + async kill( + pid: number, + opts?: Pick + ): Promise { + try { + await this.rpc.sendSignal( + { + process: { + selector: { + case: 'pid', + value: pid, + }, + }, + signal: Signal.SIGKILL, + }, + { + signal: this.connectionConfig.getSignal( + opts?.requestTimeoutMs, + opts?.signal + ), + } + ) + + return true + } catch (err) { + if (err instanceof ConnectError) { + if (err.code === Code.NotFound) { + return false + } + } + + throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) + } + } +} diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts new file mode 100644 index 0000000..2748934 --- /dev/null +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -0,0 +1,1114 @@ +import { + Client, + Code, + ConnectError, + createClient, + Transport, +} from '@connectrpc/connect' +import { + ConnectionConfig, + ConnectionOpts, + defaultUsername, + KEEPALIVE_PING_HEADER, + KEEPALIVE_PING_INTERVAL_SEC, + setupRequestController, + Username, + wrapStreamWithConnectionCleanup, +} from '../../connectionConfig' + +import { + checkSandboxHealth, + handleEnvdApiError, + handleEnvdApiFetchError, + handleWatchDirStartEvent, +} from '../../envd/api' +import { + authenticationHeader, + handleRpcErrorWithHealthCheck, + SandboxHealthCheck, +} from '../../envd/rpc' + +import { EnvdApiClient } from '../../envd/api' +import { + EntryInfo as FsEntryInfo, + Filesystem as FilesystemService, + FileType as FsFileType, +} from '../../envd/filesystem/filesystem_pb' + +import { FilesystemEvent, WatchHandle } from './watchHandle' + +import type { Timestamp } from '@bufbuild/protobuf/wkt' +import { compareVersions } from 'compare-versions' +import { + ENVD_DEFAULT_USER, + ENVD_FILE_METADATA, + ENVD_OCTET_STREAM_UPLOAD, + ENVD_VERSION_FS_EVENT_ENTRY_INFO, + ENVD_VERSION_RECURSIVE_WATCH, + ENVD_VERSION_WATCH_NETWORK_MOUNTS, +} from '../../envd/versions' +import { + FileNotFoundError, + InvalidArgumentError, + TemplateError, +} from '../../errors' +import { runtime, toBlob, toUploadBody } from '../../utils' + +const FILESYSTEM_HTTP_ERROR_MAP: Record Error> = { + 404: (message: string) => new FileNotFoundError(message), +} + +const FILESYSTEM_RPC_ERROR_MAP: Partial< + Record Error> +> = { + [Code.NotFound]: (message: string) => new FileNotFoundError(message), +} + +async function handleFilesystemRpcError( + err: unknown, + checkHealth?: SandboxHealthCheck +): Promise { + return handleRpcErrorWithHealthCheck( + err, + checkHealth, + FILESYSTEM_RPC_ERROR_MAP + ) +} + +function handleFilesystemEnvdApiError(res: { + error?: { message?: string } | string + response: Response +}) { + return handleEnvdApiError(res, FILESYSTEM_HTTP_ERROR_MAP) +} + +/** + * Sandbox filesystem object information. + */ +export interface WriteInfo { + /** + * Name of the filesystem object. + */ + name: string + /** + * Type of the filesystem object. + */ + type?: FileType + /** + * Path to the filesystem object. + */ + path: string + /** + * User-defined metadata stored on the file as `user.e2b.*` extended + * attributes. On writes this reflects the metadata supplied on upload; on + * reads (`getInfo`, `list`, `rename`) it reflects any `user.e2b.*` xattr on + * the file, including ones set out-of-band. `undefined` when none is set. + */ + metadata?: Record +} + +export interface EntryInfo extends WriteInfo { + /** + * Size of the filesystem object in bytes. + */ + size: number + + /** + * File mode and permission bits. + */ + mode: number + + /** + * String representation of file permissions (e.g. 'rwxr-xr-x'). + */ + permissions: string + + /** + * Owner of the filesystem object. + */ + owner: string + + /** + * Group owner of the filesystem object. + */ + group: string + + /** + * Last modification time of the filesystem object. + */ + modifiedTime?: Date + + /** + * If the filesystem object is a symlink, this is the target of the symlink. + */ + symlinkTarget?: string +} + +/** + * Sandbox filesystem object type. + */ +export enum FileType { + /** + * Filesystem object is a file. + */ + FILE = 'file', + /** + * Filesystem object is a directory. + */ + DIR = 'dir', +} + +export type WriteEntry = { + path: string + data: string | ArrayBuffer | Blob | ReadableStream +} + +function mapFileType(fileType: FsFileType) { + switch (fileType) { + case FsFileType.DIRECTORY: + return FileType.DIR + case FsFileType.FILE: + return FileType.FILE + } +} + +function mapModifiedTime(modifiedTime: Timestamp | undefined) { + if (!modifiedTime) return undefined + + return new Date( + Number(modifiedTime.seconds) * 1000 + + Math.floor(modifiedTime.nanos / 1_000_000) + ) +} + +function mapMetadata( + metadata: Record | undefined +): Record | undefined { + if (!metadata) return undefined + return Object.keys(metadata).length === 0 ? undefined : metadata +} + +const METADATA_HEADER_PREFIX = 'X-Metadata-' + +// Metadata keys travel as `X-Metadata-` HTTP header names, so they must be +// valid header tokens (RFC 7230); values travel as header values, restricted to +// printable US-ASCII. +const METADATA_KEY_REGEX = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/ +const METADATA_VALUE_REGEX = /^[\x20-\x7e]*$/ + +function validateMetadata(metadata: Record | undefined): void { + if (!metadata) return + for (const [key, value] of Object.entries(metadata)) { + if (!METADATA_KEY_REGEX.test(key)) { + throw new InvalidArgumentError( + `Invalid metadata key ${JSON.stringify( + key + )}: keys must be non-empty and use only HTTP token characters (letters, digits and !#$%&'*+-.^_\`|~).` + ) + } + if (!METADATA_VALUE_REGEX.test(value)) { + throw new InvalidArgumentError( + `Invalid metadata value for key ${JSON.stringify( + key + )}: values must be printable US-ASCII.` + ) + } + } +} + +function metadataHeaders( + metadata: Record | undefined +): Record { + if (!metadata) return {} + const headers: Record = {} + for (const [key, value] of Object.entries(metadata)) { + headers[`${METADATA_HEADER_PREFIX}${key}`] = value + } + return headers +} + +/** + * Map a protobuf `EntryInfo` to the SDK `EntryInfo`. + */ +export function mapEntryInfo(entry: FsEntryInfo): EntryInfo { + return { + name: entry.name, + type: mapFileType(entry.type), + path: entry.path, + size: Number(entry.size), + mode: entry.mode, + permissions: entry.permissions, + owner: entry.owner, + group: entry.group, + modifiedTime: mapModifiedTime(entry.modifiedTime), + symlinkTarget: entry.symlinkTarget, + metadata: mapMetadata(entry.metadata), + } +} + +/** + * Options for the sandbox filesystem operations. + */ +export interface FilesystemRequestOpts + extends Partial> { + /** + * User to use for the operation in the sandbox. + * This affects the resolution of relative paths and ownership of the created filesystem objects. + */ + user?: Username +} + +/** + * Options for writing files to the sandbox filesystem. + */ +export interface FilesystemWriteOpts extends FilesystemRequestOpts { + /** + * When true, the upload will be gzip-compressed. Implies the + * `application/octet-stream` upload. + * + * Requires envd 0.5.7 or later — when not supported by the sandbox's envd + * version, the upload falls back to uncompressed `multipart/form-data`. + */ + gzip?: boolean + /** + * When true, the upload uses `application/octet-stream` instead of `multipart/form-data`. + * Outside the browser, `ReadableStream` data is then streamed to the sandbox + * instead of being buffered in memory. + * + * Defaults to `undefined`, which uses octet-stream when any entry is a + * `ReadableStream` (so streamed uploads aren't buffered) and + * `multipart/form-data` otherwise; browsers always use `multipart/form-data` + * since they can't stream request bodies. Requires envd 0.5.7 or later — when + * not supported by the sandbox's envd version, the upload falls back to + * `multipart/form-data`. + */ + useOctetStream?: boolean + /** + * User-defined metadata to persist on the uploaded file(s) as extended + * attributes. Keys are lowercased by the sandbox, so they may differ in case + * when read back. Invalid keys or values throw an `InvalidArgumentError`. + * The same metadata is applied to every file in a multi-file upload. + * Requires envd 0.6.2 or later. + */ + metadata?: Record +} + +/** + * Options for reading files from the sandbox filesystem. + */ +export interface FilesystemReadOpts extends FilesystemRequestOpts { + /** + * When true, the download will request gzip-encoded responses. + */ + gzip?: boolean + /** + * Idle timeout for a streamed read (`format: 'stream'`) in **milliseconds**: + * abort if no chunk arrives from the server within this window *while + * reading*. It bounds only the wire — a slow or paused consumer never trips + * it (a consumer that holds the stream but stops reading is reclaimed + * server-side). Defaults to the request timeout (60s); pass `0` to disable. + */ + streamIdleTimeoutMs?: number +} + +export interface FilesystemListOpts extends FilesystemRequestOpts { + /** + * Depth of the directory to list. + */ + depth?: number +} + +/** + * Options for watching a directory. + */ +export interface WatchOpts extends FilesystemRequestOpts { + /** + * Timeout for the watch operation in **milliseconds**. + * You can pass `0` to disable the timeout. + * + * @default 60_000 // 60 seconds + */ + timeoutMs?: number + /** + * Callback to call when the watch operation stops. + */ + onExit?: (err?: Error) => void | Promise + /** + * Watch the directory recursively + */ + recursive?: boolean + /** + * Include the {@link EntryInfo} of the affected entry in each {@link FilesystemEvent}. + * + * The entry is populated best-effort and may be `undefined` for events where the + * entry no longer exists at the path (e.g. remove or rename-away events). + * + * Requires envd 0.6.3 or later. Watching with this option against an older sandbox + * throws a `TemplateError`. + */ + includeEntry?: boolean + /** + * Allow watching paths on network filesystem mounts (NFS, CIFS, SMB, FUSE), + * which are rejected by default. Events on network mounts may be unreliable + * or not delivered at all. + * + * Requires envd 0.6.4 or later. Watching with this option against an older sandbox + * throws a `TemplateError`. + */ + allowNetworkMounts?: boolean +} + +/** + * Module for interacting with the sandbox filesystem. + */ +export class Filesystem { + private readonly rpc: Client + + private readonly defaultWatchTimeout = 60_000 // 60 seconds + private readonly defaultWatchRecursive = false + private readonly checkHealth: SandboxHealthCheck + + constructor( + transport: Transport, + private readonly envdApi: EnvdApiClient, + private readonly connectionConfig: ConnectionConfig + ) { + this.rpc = createClient(FilesystemService, transport) + this.checkHealth = () => checkSandboxHealth(this.envdApi) + } + + /** + * Read file content as a `string`. + * + * You can pass `text`, `bytes`, `blob`, or `stream` to `opts.format` to change the return type. + * + * @param path path to the file. + * @param opts connection options. + * @param [opts.format] format of the file content—`text` by default. + * + * @returns file content as string + */ + async read( + path: string, + opts?: FilesystemReadOpts & { format?: 'text' } + ): Promise + /** + * Read file content as a `Uint8Array`. + * + * You can pass `text`, `bytes`, `blob`, or `stream` to `opts.format` to change the return type. + * + * @param path path to the file. + * @param opts connection options. + * @param [opts.format] format of the file content—`bytes`. + * + * @returns file content as `Uint8Array` + */ + async read( + path: string, + opts?: FilesystemReadOpts & { format: 'bytes' } + ): Promise + /** + * Read file content as a `Blob`. + * + * You can pass `text`, `bytes`, `blob`, or `stream` to `opts.format` to change the return type. + * + * @param path path to the file. + * @param opts connection options. + * @param [opts.format] format of the file content—`blob`. + * + * @returns file content as `Blob` + */ + async read( + path: string, + opts?: FilesystemReadOpts & { format: 'blob' } + ): Promise + /** + * Read file content as a `ReadableStream`. + * + * You can pass `text`, `bytes`, `blob`, or `stream` to `opts.format` to change the return type. + * + * The request timeout bounds only the initial handshake. The returned stream + * holds a pooled connection until it is fully read, cancelled, errors, or the + * idle timeout (`opts.streamIdleTimeoutMs`) fires—so consume it to the end or + * cancel it (`opts.signal`). + * + * @param path path to the file. + * @param opts connection options. + * @param [opts.format] format of the file content—`stream`. + * + * @returns file content as `ReadableStream` + */ + async read( + path: string, + opts?: FilesystemReadOpts & { format: 'stream' } + ): Promise> + async read( + path: string, + opts?: FilesystemReadOpts & { + format?: 'text' | 'bytes' | 'blob' | 'stream' + } + ): Promise { + const format = opts?.format ?? 'text' + + let user = opts?.user + if ( + user == undefined && + compareVersions(this.envdApi.version, ENVD_DEFAULT_USER) < 0 + ) { + user = defaultUsername + } + + const headers: Record = {} + if (opts?.gzip) { + headers['Accept-Encoding'] = 'gzip' + } + + if (format === 'stream') { + // The request timeout bounds only the initial handshake; once the + // response arrives, the stream lives until it's consumed, cancelled, the + // user signal aborts, or the per-chunk idle timeout fires. + const requestTimeoutMs = + opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs + const { controller, clearStartTimeout, cleanup } = setupRequestController( + requestTimeoutMs, + opts?.signal + ) + + try { + const res = await this.envdApi.api + .GET('/files', { + params: { + query: { + path, + username: user, + }, + }, + parseAs: 'stream', + signal: controller.signal, + headers, + }) + .catch(async (err) => { + // Map a dropped connection during the handshake (e.g. killed + // sandbox) to a typed error via the health check, matching the + // non-stream read path below. + throw await handleEnvdApiFetchError(err, this.checkHealth) + }) + + const err = await handleFilesystemEnvdApiError(res) + if (err) { + // Cancel the unconsumed error body so the pooled connection is + // released before we propagate, matching the Python stream path's + // `r.close()`. `cleanup()`'s abort would also release it, but + // cancelling is explicit and independent of runtime abort semantics. + if (res.response.body && !res.response.bodyUsed) { + await res.response.body.cancel().catch(() => {}) + } + cleanup() + throw err + } + + return wrapStreamWithConnectionCleanup( + res.data as ReadableStream | null, + { + clearStartTimeout, + cleanup, + controller, + idleTimeoutMs: opts?.streamIdleTimeoutMs ?? requestTimeoutMs, + } + ) + } catch (err) { + cleanup() + throw err + } + } + + const res = await this.envdApi.api + .GET('/files', { + params: { + query: { + path, + username: user, + }, + }, + parseAs: format === 'bytes' ? 'arrayBuffer' : format, + signal: this.connectionConfig.getSignal( + opts?.requestTimeoutMs, + opts?.signal + ), + headers, + }) + .catch(async (err) => { + throw await handleEnvdApiFetchError(err, this.checkHealth) + }) + + const err = await handleFilesystemEnvdApiError(res) + if (err) { + throw err + } + + // When the file is empty, the response body is skipped and `res.data` is + // `undefined`. Return the proper empty value for the requested format. + if (res.response.headers.get('content-length') === '0') { + if (format === 'bytes') { + return new Uint8Array(0) + } + return format === 'blob' ? new Blob([]) : '' + } + + if (format === 'bytes') { + return new Uint8Array(res.data as ArrayBuffer) + } + + return res.data + } + + /** + * Write content to a file. + * + * + * Writing to a file that doesn't exist creates the file. + * + * Writing to a file that already exists overwrites the file. + * + * Writing to a file at path that doesn't exist creates the necessary directories. + * + * @param path path to file. + * @param data data to write to the file. Data can be a string, `ArrayBuffer`, `Blob`, or `ReadableStream`. + * @param opts connection options. + * + * @returns information about the written file + */ + async write( + path: string, + data: string | ArrayBuffer | Blob | ReadableStream, + opts?: FilesystemWriteOpts + ): Promise + async write( + files: WriteEntry[], + opts?: FilesystemWriteOpts + ): Promise + async write( + pathOrFiles: string | WriteEntry[], + dataOrOpts?: + | string + | ArrayBuffer + | Blob + | ReadableStream + | FilesystemWriteOpts, + opts?: FilesystemWriteOpts + ): Promise { + if (typeof pathOrFiles !== 'string' && !Array.isArray(pathOrFiles)) { + throw new Error('Path or files are required') + } + + if (typeof pathOrFiles === 'string' && Array.isArray(dataOrOpts)) { + throw new Error( + 'Cannot specify both path and array of files. You have to specify either path and data for a single file or an array for multiple files.' + ) + } + + const { path, writeOpts, writeFiles } = + typeof pathOrFiles === 'string' + ? { + path: pathOrFiles, + writeOpts: opts as FilesystemWriteOpts, + writeFiles: [ + { + data: dataOrOpts as + | string + | ArrayBuffer + | Blob + | ReadableStream, + }, + ], + } + : { + path: undefined, + writeOpts: dataOrOpts as FilesystemWriteOpts, + writeFiles: pathOrFiles as WriteEntry[], + } + + if (writeFiles.length === 0) return [] as WriteInfo[] + + let user = writeOpts?.user + if ( + user == undefined && + compareVersions(this.envdApi.version, ENVD_DEFAULT_USER) < 0 + ) { + user = defaultUsername + } + + const useGzip = writeOpts?.gzip === true + + const supportsOctetStream = + compareVersions(this.envdApi.version, ENVD_OCTET_STREAM_UPLOAD) >= 0 + // Streaming a request body only happens on the octet-stream path; the + // multipart path buffers via `toBlob`. So default to octet-stream when any + // entry is a `ReadableStream`, otherwise a streamed upload would be + // silently buffered. Browsers can't stream request bodies, so they stay on + // multipart. Gzip also implies octet-stream (the Content-Encoding header + // applies to the whole request body). An explicit `useOctetStream` wins. + const hasStreamableData = + runtime !== 'browser' && + writeFiles.some((file) => file.data instanceof ReadableStream) + const useOctetStream = + ((writeOpts?.useOctetStream ?? hasStreamableData) || useGzip) && + supportsOctetStream + + const metadata = writeOpts?.metadata + validateMetadata(metadata) + if ( + metadata && + Object.keys(metadata).length > 0 && + compareVersions(this.envdApi.version, ENVD_FILE_METADATA) < 0 + ) { + throw new TemplateError('File metadata requires envd 0.6.2 or later.') + } + // Metadata is sent as request-scoped `X-Metadata-*` headers, so the same + // metadata is applied to every file in a multi-file upload. + const extraHeaders = metadataHeaders(metadata) + + const results: WriteInfo[] = [] + + if (useOctetStream) { + const headers: Record = { + 'Content-Type': 'application/octet-stream', + ...extraHeaders, + } + if (useGzip) { + headers['Content-Encoding'] = 'gzip' + } + + const uploadResults = await Promise.all( + writeFiles.map(async (file) => { + const filePath = path ?? (file as WriteEntry).path + const body = await toUploadBody(file.data, useGzip) + const isStream = body instanceof ReadableStream + // A streamed upload carries no client-side timeout: the socket-write + // "wire" isn't observable through fetch, and a stalled producer is + // the caller's own code, so a stuck streamed upload is bounded + // server-side (or via `writeOpts.signal`). Buffered uploads keep the + // normal request timeout. + const signal = isStream + ? writeOpts?.signal + : this.connectionConfig.getSignal( + writeOpts?.requestTimeoutMs, + writeOpts?.signal + ) + + const res = await this.envdApi.api + .POST('/files', { + params: { + query: { + path: filePath, + username: user, + }, + }, + bodySerializer: () => body, + headers, + signal, + body: {}, + // Streaming request bodies require half-duplex mode. + ...(isStream && { + duplex: 'half' as const, + }), + }) + .catch(async (err) => { + throw await handleEnvdApiFetchError(err, this.checkHealth) + }) + + const err = await handleFilesystemEnvdApiError(res) + if (err) { + throw err + } + + const files = res.data as WriteInfo[] + if (!files || files.length === 0) { + throw new Error( + 'Expected to receive information about written file' + ) + } + + for (const f of files) { + f.metadata = mapMetadata(f.metadata) + } + + return files + }) + ) + + for (const files of uploadResults) { + results.push(...files) + } + } else { + const formData = new FormData() + for (const file of writeFiles) { + formData.append( + 'file', + await toBlob(file.data), + (file as WriteEntry).path ?? path! + ) + } + + const res = await this.envdApi.api + .POST('/files', { + params: { + query: { + path, + username: user, + }, + }, + bodySerializer: () => formData, + headers: extraHeaders, + signal: this.connectionConfig.getSignal( + writeOpts?.requestTimeoutMs, + writeOpts?.signal + ), + body: {}, + }) + .catch(async (err) => { + throw await handleEnvdApiFetchError(err, this.checkHealth) + }) + + const err = await handleFilesystemEnvdApiError(res) + if (err) { + throw err + } + + const files = res.data as WriteInfo[] + if (!files || files.length === 0) { + throw new Error('Expected to receive information about written file') + } + + for (const f of files) { + f.metadata = mapMetadata(f.metadata) + } + + results.push(...files) + } + + return results.length === 1 && path ? results[0] : results + } + + /** + * Write multiple files. + * + * + * Writing to a file that doesn't exist creates the file. + * + * Writing to a file that already exists overwrites the file. + * + * Writing to a file at path that doesn't exist creates the necessary directories. + * + * @param files list of files to write as `WriteEntry` objects, each containing `path` and `data`. + * @param opts connection options. + * + * @returns information about the written files + */ + async writeFiles( + files: WriteEntry[], + opts?: FilesystemWriteOpts + ): Promise { + return this.write(files, opts) as Promise + } + + /** + * List entries in a directory. + * + * @param path path to the directory. + * @param opts connection options. + * + * @returns list of entries in the sandbox filesystem directory. + */ + async list(path: string, opts?: FilesystemListOpts): Promise { + if (typeof opts?.depth === 'number' && opts.depth < 1) { + throw new InvalidArgumentError('depth should be at least one') + } + + try { + const res = await this.rpc.listDir( + { + path, + depth: opts?.depth ?? 1, + }, + { + headers: authenticationHeader(this.envdApi.version, opts?.user), + signal: this.connectionConfig.getSignal( + opts?.requestTimeoutMs, + opts?.signal + ), + } + ) + + const entries: EntryInfo[] = [] + + for (const e of res.entries) { + // Skip entries with an unknown file type. + if (!mapFileType(e.type)) { + continue + } + + entries.push(mapEntryInfo(e)) + } + + return entries + } catch (err) { + throw await handleFilesystemRpcError(err, this.checkHealth) + } + } + + /** + * Create a new directory and all directories along the way if needed on the specified path. + * + * @param path path to a new directory. For example '/dirA/dirB' when creating 'dirB'. + * @param opts connection options. + * + * @returns `true` if the directory was created, `false` if it already exists. + */ + async makeDir(path: string, opts?: FilesystemRequestOpts): Promise { + try { + await this.rpc.makeDir( + { path }, + { + headers: authenticationHeader(this.envdApi.version, opts?.user), + signal: this.connectionConfig.getSignal( + opts?.requestTimeoutMs, + opts?.signal + ), + } + ) + + return true + } catch (err) { + if (err instanceof ConnectError) { + if (err.code === Code.AlreadyExists) { + return false + } + } + + throw await handleFilesystemRpcError(err, this.checkHealth) + } + } + + /** + * Rename a file or directory. + * + * @param oldPath path to the file or directory to rename. + * @param newPath new path for the file or directory. + * @param opts connection options. + * + * @returns information about renamed file or directory. + */ + async rename( + oldPath: string, + newPath: string, + opts?: FilesystemRequestOpts + ): Promise { + try { + const res = await this.rpc.move( + { + source: oldPath, + destination: newPath, + }, + { + headers: authenticationHeader(this.envdApi.version, opts?.user), + signal: this.connectionConfig.getSignal( + opts?.requestTimeoutMs, + opts?.signal + ), + } + ) + + const entry = res.entry + if (!entry) { + throw new Error('Expected to receive information about moved object') + } + + return mapEntryInfo(entry) + } catch (err) { + throw await handleFilesystemRpcError(err, this.checkHealth) + } + } + + /** + * Remove a file or directory. + * + * @param path path to a file or directory. + * @param opts connection options. + */ + async remove(path: string, opts?: FilesystemRequestOpts): Promise { + try { + await this.rpc.remove( + { path }, + { + headers: authenticationHeader(this.envdApi.version, opts?.user), + signal: this.connectionConfig.getSignal( + opts?.requestTimeoutMs, + opts?.signal + ), + } + ) + } catch (err) { + throw await handleFilesystemRpcError(err, this.checkHealth) + } + } + + /** + * Check if a file or a directory exists. + * + * @param path path to a file or a directory + * @param opts connection options. + * + * @returns `true` if the file or directory exists, `false` otherwise + */ + async exists(path: string, opts?: FilesystemRequestOpts): Promise { + try { + await this.rpc.stat( + { path }, + { + headers: authenticationHeader(this.envdApi.version, opts?.user), + signal: this.connectionConfig.getSignal( + opts?.requestTimeoutMs, + opts?.signal + ), + } + ) + + return true + } catch (err) { + if (err instanceof ConnectError) { + if (err.code === Code.NotFound) { + return false + } + } + + throw await handleFilesystemRpcError(err, this.checkHealth) + } + } + + /** + * Get information about a file or directory. + * + * @param path path to a file or directory. + * @param opts connection options. + * + * @returns information about the file or directory like name, type, and path. + */ + async getInfo( + path: string, + opts?: FilesystemRequestOpts + ): Promise { + try { + const res = await this.rpc.stat( + { path }, + { + headers: authenticationHeader(this.envdApi.version, opts?.user), + signal: this.connectionConfig.getSignal( + opts?.requestTimeoutMs, + opts?.signal + ), + } + ) + + if (!res.entry) { + throw new Error( + 'Expected to receive information about the file or directory' + ) + } + + return mapEntryInfo(res.entry) + } catch (err) { + throw await handleFilesystemRpcError(err, this.checkHealth) + } + } + + /** + * Start watching a directory for filesystem events. + * + * @param path path to directory to watch. + * @param onEvent callback to call when an event in the directory occurs. + * @param opts connection options. + * + * @returns `WatchHandle` object for stopping watching directory. + */ + async watchDir( + path: string, + onEvent: (event: FilesystemEvent) => void | Promise, + opts?: WatchOpts & { + onExit?: (err?: Error) => void | Promise + } + ): Promise { + if ( + opts?.recursive && + this.envdApi.version && + compareVersions(this.envdApi.version, ENVD_VERSION_RECURSIVE_WATCH) < 0 + ) { + throw new TemplateError( + 'You need to update the template to use recursive watching.' + ) + } + + if ( + opts?.includeEntry && + this.envdApi.version && + compareVersions(this.envdApi.version, ENVD_VERSION_FS_EVENT_ENTRY_INFO) < + 0 + ) { + throw new TemplateError( + 'You need to update the template to include entry info in watch events.' + ) + } + + if ( + opts?.allowNetworkMounts && + this.envdApi.version && + compareVersions(this.envdApi.version, ENVD_VERSION_WATCH_NETWORK_MOUNTS) < + 0 + ) { + throw new TemplateError( + 'You need to update the template to watch directories on network mounts.' + ) + } + + const requestTimeoutMs = + opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs + + const { controller, clearStartTimeout, cleanup } = setupRequestController( + requestTimeoutMs, + opts?.signal + ) + + const events = this.rpc.watchDir( + { + path, + recursive: opts?.recursive ?? this.defaultWatchRecursive, + includeEntry: opts?.includeEntry ?? false, + allowNetworkMounts: opts?.allowNetworkMounts ?? false, + }, + { + headers: { + ...authenticationHeader(this.envdApi.version, opts?.user), + [KEEPALIVE_PING_HEADER]: KEEPALIVE_PING_INTERVAL_SEC.toString(), + }, + signal: controller.signal, + timeoutMs: opts?.timeoutMs ?? this.defaultWatchTimeout, + } + ) + + try { + await handleWatchDirStartEvent(events) + clearStartTimeout() + + return new WatchHandle( + cleanup, + events, + onEvent, + opts?.onExit, + this.checkHealth + ) + } catch (err) { + cleanup() + throw await handleFilesystemRpcError(err, this.checkHealth) + } + } +} diff --git a/packages/js-sdk/src/sandbox/filesystem/watchHandle.ts b/packages/js-sdk/src/sandbox/filesystem/watchHandle.ts new file mode 100644 index 0000000..cf16fc6 --- /dev/null +++ b/packages/js-sdk/src/sandbox/filesystem/watchHandle.ts @@ -0,0 +1,152 @@ +import { + handleRpcErrorWithHealthCheck, + SandboxHealthCheck, +} from '../../envd/rpc' +import { + EventType, + WatchDirResponse, +} from '../../envd/filesystem/filesystem_pb' +import { EntryInfo, mapEntryInfo } from './index' + +/** + * Sandbox filesystem event types. + */ +export enum FilesystemEventType { + /** + * Filesystem object permissions were changed. + */ + CHMOD = 'chmod', + /** + * Filesystem object was created. + */ + CREATE = 'create', + /** + * Filesystem object was removed. + */ + REMOVE = 'remove', + /** + * Filesystem object was renamed. + */ + RENAME = 'rename', + /** + * Filesystem object was written to. + */ + WRITE = 'write', +} + +function mapEventType(type: EventType) { + switch (type) { + case EventType.CHMOD: + return FilesystemEventType.CHMOD + case EventType.CREATE: + return FilesystemEventType.CREATE + case EventType.REMOVE: + return FilesystemEventType.REMOVE + case EventType.RENAME: + return FilesystemEventType.RENAME + case EventType.WRITE: + return FilesystemEventType.WRITE + } +} + +/** + * Information about a filesystem event. + */ +export interface FilesystemEvent { + /** + * Relative path to the filesystem object. + */ + name: string + /** + * Filesystem operation event type. + */ + type: FilesystemEventType + /** + * Information about the entry that triggered the event. + * + * Only populated when the watch was started with `includeEntry: true` and the + * sandbox's envd version supports it. It may be `undefined` for events where the + * entry no longer exists at the path (e.g. remove or rename-away events). + */ + entry?: EntryInfo +} + +/** + * Handle for watching a directory in the sandbox filesystem. + * + * Use {@link WatchHandle.stop} to stop watching the directory. + */ +export class WatchHandle { + constructor( + private readonly handleStop: () => void, + private readonly events: AsyncIterable, + private readonly onEvent?: (event: FilesystemEvent) => void | Promise, + private readonly onExit?: (err?: Error) => void | Promise, + private readonly checkHealth?: SandboxHealthCheck + ) { + this.handleEvents() + } + + /** + * Stop watching the directory. + */ + async stop() { + this.handleStop() + } + + private async *iterateEvents() { + try { + for await (const event of this.events) { + switch (event.event.case) { + case 'filesystem': + yield event.event + break + } + } + } catch (err) { + throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) + } + } + + private async handleEvents() { + let iterationError: Error | undefined + try { + for await (const event of this.iterateEvents()) { + const eventType = mapEventType(event.value.type) + if (eventType === undefined) { + continue + } + + // Await the callback so an async `onEvent` that rejects is routed to + // `onExit` below (instead of becoming an unhandled promise rejection + // that can crash Node) and so the user can apply backpressure. Mirrors + // `CommandHandle.handleEvents`. + await this.onEvent?.({ + name: event.value.name, + type: eventType, + entry: event.value.entry + ? mapEntryInfo(event.value.entry) + : undefined, + }) + } + } catch (err) { + iterationError = err as Error + } + + try { + // Invoke `onExit` exactly once: with the error when the watch ended + // because of one, or with no argument on a clean end. + if (iterationError) { + await this.onExit?.(iterationError) + } else { + await this.onExit?.() + } + } catch { + // `onExit` is the terminal callback; an error it throws has nowhere to + // propagate in this detached handler, so it's swallowed to avoid an + // unhandled promise rejection (the failure mode this guards against). + } finally { + this.handleStop() + } + } +} diff --git a/packages/js-sdk/src/sandbox/git/index.ts b/packages/js-sdk/src/sandbox/git/index.ts new file mode 100644 index 0000000..f50a864 --- /dev/null +++ b/packages/js-sdk/src/sandbox/git/index.ts @@ -0,0 +1,1054 @@ +import { + GitAuthError, + GitUpstreamError, + InvalidArgumentError, +} from '../../errors' +import { shellQuote } from '../../utils' +import type { CommandStartOpts } from '../commands' +import type { CommandResult } from '../commands/commandHandle' +import { Commands } from '../commands' +import { + buildAuthErrorMessage, + buildGitCommand, + buildPushArgs, + buildUpstreamErrorMessage, + GitBranches, + GitConfigScope, + GitStatus, + getRepoPathForScope, + getScopeFlag, + isAuthFailure, + isMissingUpstream, + parseGitBranches, + parseGitStatus, + stripCredentials, + deriveRepoDirFromUrl, + withCredentials, +} from './utils' + +const DEFAULT_GIT_ENV: Record = { + GIT_TERMINAL_PROMPT: '0', +} + +/** + * Options for git operations in the sandbox. + */ +export interface GitRequestOpts + extends Partial< + Pick< + CommandStartOpts, + 'envs' | 'user' | 'cwd' | 'timeoutMs' | 'requestTimeoutMs' + > + > {} + +/** + * Options for cloning a repository. + */ +export interface GitCloneOpts extends GitRequestOpts { + /** + * Destination path for the clone. + */ + path?: string + /** + * Branch to check out. + */ + branch?: string + /** + * If set, perform a shallow clone with this depth. + */ + depth?: number + /** + * Username for HTTP(S) authentication. + */ + username?: string + /** + * Password or token for HTTP(S) authentication. + */ + password?: string + /** + * Store credentials in the cloned repository when `true`. + * + * @default false + */ + dangerouslyStoreCredentials?: boolean +} + +/** + * Options for initializing a repository. + */ +export interface GitInitOpts extends GitRequestOpts { + /** + * Create a bare repository when `true`. + */ + bare?: boolean + /** + * Initial branch name (for example, `"main"`). + */ + initialBranch?: string +} + +/** + * Options for adding a git remote. + */ +export interface GitRemoteAddOpts extends GitRequestOpts { + /** + * Fetch the remote after adding it when `true`. + */ + fetch?: boolean + /** + * Overwrite the remote URL if the remote already exists when `true`. + */ + overwrite?: boolean +} + +/** + * Options for creating a commit. + */ +export interface GitCommitOpts extends GitRequestOpts { + /** + * Commit author name. + */ + authorName?: string + /** + * Commit author email. + */ + authorEmail?: string + /** + * Allow empty commits when `true`. + */ + allowEmpty?: boolean +} + +/** + * Options for staging files. + */ +export interface GitAddOpts extends GitRequestOpts { + /** + * Files to add; when omitted, adds the current directory. + */ + files?: string[] + /** + * When `true` and `files` is omitted, stage all changes. + */ + all?: boolean +} + +/** + * Supported reset modes. + */ +export type GitResetMode = 'soft' | 'mixed' | 'hard' | 'merge' | 'keep' + +/** + * Options for resetting a repository. + */ +export interface GitResetOpts extends GitRequestOpts { + /** + * Reset mode to use. + */ + mode?: GitResetMode + /** + * Commit, branch, or ref to reset to (defaults to HEAD). + */ + target?: string + /** + * Paths to reset. + */ + paths?: string[] +} + +/** + * Options for restoring files or unstaging changes. + */ +export interface GitRestoreOpts extends GitRequestOpts { + /** + * Paths to restore (use `['.']` for all). + */ + paths: string[] + /** + * Restore the index (unstage). + */ + staged?: boolean + /** + * Restore working tree files. + */ + worktree?: boolean + /** + * Restore from the given source (commit, branch, or ref). + */ + source?: string +} +/** + * Options for deleting a branch. + */ +export interface GitDeleteBranchOpts extends GitRequestOpts { + /** + * Force deletion with `-D` when `true`. + */ + force?: boolean +} + +/** + * Options for pushing commits. + */ +export interface GitPushOpts extends GitRequestOpts { + /** + * Remote name (for example, `"origin"`). + */ + remote?: string + /** + * Branch name to push. + */ + branch?: string + /** + * Set upstream tracking when `true`. + */ + setUpstream?: boolean + /** + * Username for HTTP(S) authentication. + */ + username?: string + /** + * Password or token for HTTP(S) authentication. + */ + password?: string +} + +/** + * Options for pulling commits. + */ +export interface GitPullOpts extends GitRequestOpts { + /** + * Remote name (for example, `"origin"`). + */ + remote?: string + /** + * Branch name to pull. + */ + branch?: string + /** + * Username for HTTP(S) authentication. + */ + username?: string + /** + * Password or token for HTTP(S) authentication. + */ + password?: string +} + +/** + * Supported scopes for git config operations. + */ +/** + * Options for git config operations. + */ +export interface GitConfigOpts extends GitRequestOpts { + /** + * Scope for the git config command. + * + * @default "global" + */ + scope?: GitConfigScope + /** + * Repository path required when `scope` is `"local"`. + */ + path?: string +} + +/** + * Options for dangerously authenticating git globally via the credential helper. + */ +export interface GitDangerouslyAuthenticateOpts extends GitRequestOpts { + /** + * Username for HTTP(S) authentication. + */ + username: string + /** + * Password or token for HTTP(S) authentication. + */ + password: string + /** + * Host to authenticate for. + * + * @default "github.com" + */ + host?: string + /** + * Protocol to authenticate for. + * + * @default "https" + */ + protocol?: string +} + +/** + * Module for running git operations in the sandbox. + */ +export class Git { + constructor(private readonly commands: Commands) {} + + /** + * Clone a git repository into the sandbox. + * + * @param url Git repository URL. + * @param opts Clone options. + * @returns Command result from the command runner. + */ + async clone(url: string, opts?: GitCloneOpts): Promise { + const { + username, + password, + branch, + depth, + path, + dangerouslyStoreCredentials, + ...rest + } = opts ?? {} + + if (password && !username) { + throw new InvalidArgumentError( + 'Username is required when using a password or token for git clone.' + ) + } + + const attemptClone = async ( + authUsername?: string, + authPassword?: string + ): Promise => { + const urlWithCreds = + authUsername && authPassword + ? withCredentials(url, authUsername, authPassword) + : url + + const sanitizedUrl = stripCredentials(urlWithCreds) + const stripInlineCreds = + !dangerouslyStoreCredentials && sanitizedUrl !== urlWithCreds + + const repoPath = stripInlineCreds + ? (path ?? deriveRepoDirFromUrl(url)) + : path + + if (stripInlineCreds && !repoPath) { + throw new InvalidArgumentError( + 'A destination path is required when using credentials without storing them.' + ) + } + + const args = ['clone', urlWithCreds] + + if (branch) args.push('--branch', branch, '--single-branch') + if (depth) args.push('--depth', depth.toString()) + if (repoPath) args.push(repoPath) + + const result = await this.runGit(args, undefined, rest) + + if (stripInlineCreds) { + await this.runGit( + ['remote', 'set-url', 'origin', sanitizedUrl], + repoPath, + rest + ) + } + + return result + } + + try { + return await attemptClone(username, password) + } catch (err) { + if (isAuthFailure(err)) { + throw new GitAuthError( + buildAuthErrorMessage('clone', Boolean(username) && !password) + ) + } + throw err + } + } + + /** + * Initialize a new git repository. + * + * @param path Destination path for the repository. + * @param opts Init options. + * @returns Command result from the command runner. + */ + async init(path: string, opts?: GitInitOpts): Promise { + const { bare, initialBranch, ...rest } = opts ?? {} + const args = ['init'] + + if (initialBranch) { + args.push('--initial-branch', initialBranch) + } + if (bare) { + args.push('--bare') + } + + args.push(path) + return this.runGit(args, undefined, rest) + } + + /** + * Add (or update) a remote for a repository. + * + * @param path Repository path. + * @param name Remote name (for example, `"origin"`). + * @param url Remote URL. + * @param opts Remote add options. + * @returns Command result from the command runner. + */ + async remoteAdd( + path: string, + name: string, + url: string, + opts?: GitRemoteAddOpts + ): Promise { + if (!name || !url) { + throw new InvalidArgumentError( + 'Both remote name and URL are required to add a git remote.' + ) + } + + const { fetch, overwrite, ...rest } = opts ?? {} + const addArgs = ['remote', 'add'] + + if (fetch) { + addArgs.push('-f') + } + + addArgs.push(name, url) + + if (!overwrite) { + return this.runGit(addArgs, path, rest) + } + + const addCmd = buildGitCommand(addArgs, path) + const setUrlCmd = buildGitCommand(['remote', 'set-url', name, url], path) + let cmd = `${addCmd} || ${setUrlCmd}` + if (fetch) { + const fetchCmd = buildGitCommand(['fetch', name], path) + cmd = `(${cmd}) && ${fetchCmd}` + } + return this.runShell(cmd, rest) + } + + /** + * Get the URL for a git remote. + * + * Returns `undefined` when the remote does not exist. + * + * @param path Repository path. + * @param name Remote name (for example, `"origin"`). + * @param opts Command execution options. + * @returns Remote URL if present. + */ + async remoteGet( + path: string, + name: string, + opts?: GitRequestOpts + ): Promise { + if (!name) { + throw new InvalidArgumentError('Remote name is required.') + } + + const cmd = `${buildGitCommand(['remote', 'get-url', name], path)} || true` + const result = await this.runShell(cmd, opts) + const trimmed = result.stdout.trim() + return trimmed.length > 0 ? trimmed : undefined + } + + /** + * Get repository status information. + * + * @param path Repository path. + * @param opts Command execution options. + * @returns Parsed git status. + */ + async status(path: string, opts?: GitRequestOpts): Promise { + const result = await this.runGit( + ['status', '--porcelain=1', '-b'], + path, + opts + ) + return parseGitStatus(result.stdout) + } + + /** + * List branches in a repository. + * + * @param path Repository path. + * @param opts Command execution options. + * @returns Parsed branch list. + */ + async branches(path: string, opts?: GitRequestOpts): Promise { + const result = await this.runGit( + ['branch', '--format=%(refname:short)\t%(HEAD)'], + path, + opts + ) + return parseGitBranches(result.stdout) + } + + /** + * Create and check out a new branch. + * + * @param path Repository path. + * @param branch Branch name to create. + * @param opts Command execution options. + * @returns Command result from the command runner. + */ + async createBranch( + path: string, + branch: string, + opts?: GitRequestOpts + ): Promise { + return this.runGit(['checkout', '-b', branch], path, opts) + } + + /** + * Check out an existing branch. + * + * @param path Repository path. + * @param branch Branch name to check out. + * @param opts Command execution options. + * @returns Command result from the command runner. + */ + async checkoutBranch( + path: string, + branch: string, + opts?: GitRequestOpts + ): Promise { + return this.runGit(['checkout', branch], path, opts) + } + + /** + * Delete a branch. + * + * @param path Repository path. + * @param branch Branch name to delete. + * @param opts Delete options. + * @returns Command result from the command runner. + */ + async deleteBranch( + path: string, + branch: string, + opts?: GitDeleteBranchOpts + ): Promise { + const { force, ...rest } = opts ?? {} + const args = ['branch', force ? '-D' : '-d', branch] + return this.runGit(args, path, rest) + } + + /** + * Stage files for commit. + * + * @param path Repository path. + * @param opts Add options. + * @returns Command result from the command runner. + */ + async add(path: string, opts?: GitAddOpts): Promise { + const { files, all = true, ...rest } = opts ?? {} + const args = ['add'] + + if (!files || files.length === 0) { + args.push(all ? '-A' : '.') + } else { + args.push('--', ...files) + } + + return this.runGit(args, path, rest) + } + + /** + * Create a commit in the repository. + * + * @param path Repository path. + * @param message Commit message. + * @param opts Commit options. + * @returns Command result from the command runner. + */ + async commit( + path: string, + message: string, + opts?: GitCommitOpts + ): Promise { + const { authorName, authorEmail, allowEmpty, ...rest } = opts ?? {} + const args = ['commit', '-m', message] + + if (allowEmpty) { + args.push('--allow-empty') + } + + const authorArgs: string[] = [] + if (authorName) { + authorArgs.push('-c', `user.name=${authorName}`) + } + if (authorEmail) { + authorArgs.push('-c', `user.email=${authorEmail}`) + } + + return this.runGit([...authorArgs, ...args], path, rest) + } + + /** + * Reset the current HEAD to a specified state. + * + * @param path Repository path. + * @param opts Reset options. + * @returns Command result from the command runner. + */ + async reset(path: string, opts?: GitResetOpts): Promise { + const { mode, target, paths, ...rest } = opts ?? {} + const allowedModes: GitResetMode[] = [ + 'soft', + 'mixed', + 'hard', + 'merge', + 'keep', + ] + + if (mode && !allowedModes.includes(mode)) { + throw new InvalidArgumentError( + `Reset mode must be one of ${allowedModes.join(', ')}.` + ) + } + + const args = ['reset'] + if (mode) { + args.push(`--${mode}`) + } + if (target) { + args.push(target) + } + if (paths && paths.length > 0) { + args.push('--', ...paths) + } + + return this.runGit(args, path, rest) + } + + /** + * Restore working tree files or unstage changes. + * + * @param path Repository path. + * @param opts Restore options. + * @returns Command result from the command runner. + */ + async restore(path: string, opts: GitRestoreOpts): Promise { + const { paths, staged, worktree, source, ...rest } = opts + + if (!paths || paths.length === 0) { + throw new InvalidArgumentError('At least one path is required.') + } + + let resolvedStaged = staged + let resolvedWorktree = worktree + + if (staged === undefined && worktree === undefined) { + resolvedWorktree = true + } else if (staged === true && worktree === undefined) { + resolvedWorktree = false + } else if (staged === undefined && worktree !== undefined) { + resolvedStaged = false + } + + if (resolvedStaged === false && resolvedWorktree === false) { + throw new InvalidArgumentError( + 'At least one of staged or worktree must be true.' + ) + } + + const args = ['restore'] + if (resolvedWorktree) { + args.push('--worktree') + } + if (resolvedStaged) { + args.push('--staged') + } + if (source) { + args.push('--source', source) + } + args.push('--', ...paths) + + return this.runGit(args, path, rest) + } + + /** + * Push commits to a remote. + * + * @param path Repository path. + * @param opts Push options. + * @returns Command result from the command runner. + */ + async push(path: string, opts?: GitPushOpts): Promise { + const { + remote, + branch, + setUpstream = true, + username, + password, + ...rest + } = opts ?? {} + + if (password && !username) { + throw new InvalidArgumentError( + 'Username is required when using a password or token for git push.' + ) + } + + if (username && password) { + const remoteName = await this.resolveRemoteName(path, remote, rest) + return this.withRemoteCredentials( + path, + remoteName, + username, + password, + rest, + () => + this.runGit( + buildPushArgs(remoteName, { remote, branch, setUpstream }), + path, + rest + ) + ) + } + + try { + return await this.runGit( + buildPushArgs(undefined, { remote, branch, setUpstream }), + path, + rest + ) + } catch (err) { + if (isAuthFailure(err)) { + throw new GitAuthError( + buildAuthErrorMessage('push', Boolean(username) && !password) + ) + } + if (isMissingUpstream(err)) { + throw new GitUpstreamError(buildUpstreamErrorMessage('push')) + } + throw err + } + } + + /** + * Pull changes from a remote. + * + * @param path Repository path. + * @param opts Pull options. + * @returns Command result from the command runner. + */ + async pull(path: string, opts?: GitPullOpts): Promise { + const { remote, branch, username, password, ...rest } = opts ?? {} + if (password && !username) { + throw new InvalidArgumentError( + 'Username is required when using a password or token for git pull.' + ) + } + + if (!remote && !branch) { + const hasUpstream = await this.hasUpstream(path, rest) + if (!hasUpstream) { + throw new GitUpstreamError(buildUpstreamErrorMessage('pull')) + } + } + + const buildArgs = (remoteName?: string) => { + const args = ['pull'] + const targetRemote = remoteName ?? remote + if (targetRemote) { + args.push(targetRemote) + } + if (branch) { + args.push(branch) + } + return args + } + + if (username && password) { + const remoteName = await this.resolveRemoteName(path, remote, rest) + return this.withRemoteCredentials( + path, + remoteName, + username, + password, + rest, + () => this.runGit(buildArgs(remoteName), path, rest) + ) + } + + try { + return await this.runGit(buildArgs(), path, rest) + } catch (err) { + if (isAuthFailure(err)) { + throw new GitAuthError( + buildAuthErrorMessage('pull', Boolean(username) && !password) + ) + } + if (isMissingUpstream(err)) { + throw new GitUpstreamError(buildUpstreamErrorMessage('pull')) + } + throw err + } + } + + /** + * Set a git config value. + * + * Use `scope: "local"` together with `path` to configure a specific repository. + * + * @param key Git config key (for example, `"pull.rebase"`). + * @param value Git config value. + * @param opts Config options. + * @returns Command result from the command runner. + */ + async setConfig( + key: string, + value: string, + opts?: GitConfigOpts + ): Promise { + if (!key) { + throw new InvalidArgumentError('Git config key is required.') + } + + const scope = opts?.scope ?? 'global' + const scopeFlag = getScopeFlag(scope) + const repoPath = getRepoPathForScope(scope, opts?.path) + + return this.runGit(['config', scopeFlag, key, value], repoPath, opts) + } + + /** + * Get a git config value. + * + * Returns `undefined` when the key is not set in the requested scope. + * + * @param key Git config key (for example, `"pull.rebase"`). + * @param opts Config options. + * @returns The config value if present. + */ + async getConfig( + key: string, + opts?: GitConfigOpts + ): Promise { + if (!key) { + throw new InvalidArgumentError('Git config key is required.') + } + + const scope = opts?.scope ?? 'global' + const scopeFlag = getScopeFlag(scope) + const repoPath = getRepoPathForScope(scope, opts?.path) + const cmd = `${buildGitCommand(['config', scopeFlag, '--get', key], repoPath)} || true` + const result = await this.runShell(cmd, opts) + const trimmed = result.stdout.trim() + return trimmed.length > 0 ? trimmed : undefined + } + + /** + * Dangerously authenticate git globally via the credential helper. + * + * This persists credentials in the credential store. + * Prefer short-lived credentials when possible. + * + * @param opts Authentication options. + * @returns Command result from the command runner. + */ + async dangerouslyAuthenticate( + opts: GitDangerouslyAuthenticateOpts + ): Promise { + const { username, password, host, protocol, ...rest } = opts + + if (!username || !password) { + throw new InvalidArgumentError( + 'Both username and password are required to authenticate git.' + ) + } + + const targetHost = (host ?? 'github.com').trim() + const targetProtocol = (protocol ?? 'https').trim() + const credentialInput = [ + `protocol=${targetProtocol}`, + `host=${targetHost}`, + `username=${username}`, + `password=${password}`, + '', + '', + ].join('\n') + + await this.runGit( + ['config', '--global', 'credential.helper', 'store'], + undefined, + rest + ) + + const approveCmd = `printf %s ${shellQuote(credentialInput)} | ${buildGitCommand( + ['credential', 'approve'] + )}` + + return this.runShell(approveCmd, rest) + } + + /** + * Configure git user name and email. + * + * @param name Git user name. + * @param email Git user email. + * @param opts Config options. + * @returns Command result from the command runner. + */ + async configureUser( + name: string, + email: string, + opts?: GitConfigOpts + ): Promise { + if (!name || !email) { + throw new InvalidArgumentError('Both name and email are required.') + } + + const scope = opts?.scope ?? 'global' + const configOpts = { ...opts, scope } + + await this.setConfig('user.name', name, configOpts) + return this.setConfig('user.email', email, configOpts) + } + + /** + * Build and execute a git command inside the sandbox. + * + * @param args Git arguments to pass to the git binary. + * @param repoPath Repository path used with `git -C`, if provided. + * @param opts Command execution options. + * @returns Command result from the command runner. + */ + private async runGit( + args: string[], + repoPath?: string, + opts?: GitRequestOpts + ): Promise { + const { envs, ...rest } = opts ?? {} + const cmd = buildGitCommand(args, repoPath) + const mergedEnvs = { ...DEFAULT_GIT_ENV, ...(envs ?? {}) } + + return this.commands.run(cmd, { + ...rest, + envs: mergedEnvs, + }) + } + + /** + * Execute a raw shell command while applying default git environment variables. + * + Note: We can likely just modify runGit later to allow appending commands to the git but for now it's separate. + */ + private async runShell( + cmd: string, + opts?: GitRequestOpts + ): Promise { + const { envs, ...rest } = opts ?? {} + const mergedEnvs = { ...DEFAULT_GIT_ENV, ...(envs ?? {}) } + + return this.commands.run(cmd, { + ...rest, + envs: mergedEnvs, + }) + } + + private async getRemoteUrl( + path: string, + remote: string, + opts?: GitRequestOpts + ): Promise { + const result = await this.runGit(['remote', 'get-url', remote], path, opts) + const url = result.stdout.trim() + if (!url) { + throw new InvalidArgumentError( + `Remote "${remote}" URL not found in repository.` + ) + } + return url + } + + private async resolveRemoteName( + path: string, + remote: string | undefined, + opts?: GitRequestOpts + ): Promise { + if (remote) { + return remote + } + + const result = await this.runGit(['remote'], path, opts) + const remotes = result.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + + if (remotes.length === 1) { + return remotes[0] + } + + throw new InvalidArgumentError( + 'Remote is required when using username/password and the repository has multiple remotes.' + ) + } + + private async withRemoteCredentials( + path: string, + remote: string, + username: string, + password: string, + opts: GitRequestOpts | undefined, + operation: () => Promise + ): Promise { + const originalUrl = await this.getRemoteUrl(path, remote, opts) + const credentialUrl = withCredentials(originalUrl, username, password) + + await this.runGit(['remote', 'set-url', remote, credentialUrl], path, opts) + + let result: T | undefined + let operationError: unknown + try { + result = await operation() + } catch (err) { + operationError = err + } + + let restoreError: unknown + try { + await this.runGit(['remote', 'set-url', remote, originalUrl], path, opts) + } catch (err) { + restoreError = err + } + + if (operationError) { + throw operationError + } + if (restoreError) { + throw restoreError + } + + return result as T + } + + private async hasUpstream( + path: string, + opts?: GitRequestOpts + ): Promise { + try { + const result = await this.runGit( + ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], + path, + opts + ) + return result.stdout.trim().length > 0 + } catch { + return false + } + } +} + +export type { + GitBranches, + GitConfigScope, + GitFileStatus, + GitStatus, +} from './utils' diff --git a/packages/js-sdk/src/sandbox/git/utils.ts b/packages/js-sdk/src/sandbox/git/utils.ts new file mode 100644 index 0000000..6df7fbc --- /dev/null +++ b/packages/js-sdk/src/sandbox/git/utils.ts @@ -0,0 +1,587 @@ +import { InvalidArgumentError } from '../../errors' +import { shellQuote } from '../../utils' +import { CommandExitError } from '../commands/commandHandle' + +/** + * Parsed git status entry for a file. + */ +export interface GitFileStatus { + /** + * Path relative to the repository root. + */ + name: string + /** + * Normalized status string (for example, `"modified"` or `"added"`). + */ + status: GitStatusLabel + /** + * Index status character from porcelain output. + */ + indexStatus: string + /** + * Working tree status character from porcelain output. + */ + workingTreeStatus: string + /** + * Whether the change is staged. + */ + staged: boolean + /** + * Original path when the file was renamed. + */ + renamedFrom?: string +} + +/** + * Supported normalized git status labels. + */ +export type GitStatusLabel = + | 'conflict' + | 'renamed' + | 'copied' + | 'deleted' + | 'added' + | 'modified' + | 'typechange' + | 'untracked' + | 'unknown' + +/** + * Scope for git config operations. + */ +export type GitConfigScope = 'global' | 'local' | 'system' + +/** + * Parsed git repository status. + */ +export interface GitStatus { + /** + * Current branch name, if available. + */ + currentBranch?: string + /** + * Upstream branch name, if available. + */ + upstream?: string + /** + * Number of commits the branch is ahead of upstream. + */ + ahead: number + /** + * Number of commits the branch is behind upstream. + */ + behind: number + /** + * Whether HEAD is detached. + */ + detached: boolean + /** + * List of file status entries. + */ + fileStatus: GitFileStatus[] + /** + * Whether the repository has no tracked or untracked file changes. + */ + isClean: boolean + /** + * Whether the repository has any tracked or untracked file changes. + */ + hasChanges: boolean + /** + * Whether there are staged changes. + */ + hasStaged: boolean + /** + * Whether there are untracked files. + */ + hasUntracked: boolean + /** + * Whether there are merge conflicts. + */ + hasConflicts: boolean + /** + * Total number of changed files. + */ + totalCount: number + /** + * Number of files with staged changes. + */ + stagedCount: number + /** + * Number of files with unstaged changes. + */ + unstagedCount: number + /** + * Number of untracked files. + */ + untrackedCount: number + /** + * Number of files with merge conflicts. + */ + conflictCount: number +} + +/** + * Parsed git branch list. + */ +export interface GitBranches { + /** + * List of branch names. + */ + branches: string[] + /** + * Current branch name, if available. + */ + currentBranch?: string +} + +/** + * Add HTTP(S) credentials to a Git URL. + * + * @param url Git repository URL. + * @param username Username for HTTP(S) authentication. + * @param password Password or token for HTTP(S) authentication. + * @returns URL with embedded credentials. + */ +export function withCredentials( + url: string, + username?: string, + password?: string +): string { + if (!username && !password) { + return url + } + + if (!username || !password) { + throw new InvalidArgumentError( + 'Both username and password are required when using Git credentials.' + ) + } + + let parsed: URL + try { + parsed = new URL(url) + } catch { + throw new InvalidArgumentError(`Invalid Git URL: ${url}`) + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new InvalidArgumentError( + 'Only http(s) Git URLs support username/password credentials.' + ) + } + + parsed.username = username + parsed.password = password + + return parsed.toString() +} + +/** + * Strip HTTP(S) credentials from a Git URL. + * + * @param url Git repository URL. + * @returns URL without embedded credentials. + */ +export function stripCredentials(url: string): string { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return url + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return url + } + + if (!parsed.username && !parsed.password) { + return url + } + + parsed.username = '' + parsed.password = '' + return parsed.toString() +} + +/** + * Derive the default repository directory name from a Git URL. + * + * @param url Git repository URL. + * @returns Repository directory name, if it can be determined. + */ +export function deriveRepoDirFromUrl(url: string): string | undefined { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return undefined + } + + const trimmedPath = parsed.pathname.replace(/\/+$/, '') + const lastSegment = trimmedPath.split('/').pop() + if (!lastSegment) { + return undefined + } + + return lastSegment.endsWith('.git') ? lastSegment.slice(0, -4) : lastSegment +} + +/** + * Build a shell-safe git command string. + * + * @param args Git command arguments. + * @param repoPath Repository path for `git -C`, if provided. + * @returns Shell-safe git command. + */ +export function buildGitCommand(args: string[], repoPath?: string): string { + const parts = ['git'] + if (repoPath) { + parts.push('-C', repoPath) + } + parts.push(...args) + + return parts.map((part) => shellQuote(part)).join(' ') +} + +type GitPushArgsOptions = { + remote?: string + branch?: string + setUpstream: boolean +} + +export function buildPushArgs( + remoteName: string | undefined, + opts: GitPushArgsOptions +): string[] { + const { remote, branch, setUpstream } = opts + const args = ['push'] + const targetRemote = remoteName ?? remote + if (setUpstream && targetRemote) { + args.push('--set-upstream') + } + if (targetRemote) { + args.push(targetRemote) + } + if (branch) { + args.push(branch) + } + return args +} + +function parseAheadBehind(segment?: string): { ahead: number; behind: number } { + if (!segment) { + return { ahead: 0, behind: 0 } + } + + let ahead = 0 + let behind = 0 + + if (segment.includes('ahead')) { + try { + ahead = Number.parseInt( + segment.split('ahead')[1].split(',')[0].trim(), + 10 + ) + } catch { + ahead = 0 + } + } + + if (segment.includes('behind')) { + try { + behind = Number.parseInt( + segment.split('behind')[1].split(',')[0].trim(), + 10 + ) + } catch { + behind = 0 + } + } + + return { ahead, behind } +} + +function normalizeBranchName(name: string): string { + if (name.startsWith('HEAD (detached at ')) { + return name.replace('HEAD (detached at ', '').replace(/\)$/, '') + } + + return name + .replace('HEAD (no branch)', 'HEAD') + .replace('No commits yet on ', '') + .replace('Initial commit on ', '') +} + +function deriveStatus( + indexStatus: string, + workingStatus: string +): GitStatusLabel { + const statuses = new Set([indexStatus, workingStatus]) + + if (statuses.has('U')) return 'conflict' + if (statuses.has('R')) return 'renamed' + if (statuses.has('C')) return 'copied' + if (statuses.has('D')) return 'deleted' + if (statuses.has('A')) return 'added' + if (statuses.has('M')) return 'modified' + if (statuses.has('T')) return 'typechange' + if (statuses.has('?')) return 'untracked' + + return 'unknown' +} + +/** + * Parse `git status --porcelain=1 -b` output into a structured object. + * + * @param output Git status output. + * @returns Parsed {@link GitStatus}. + */ +export function parseGitStatus(output: string): GitStatus { + const lines = output + .split('\n') + .map((line) => line.replace(/\r$/, '')) + .filter((line) => line.trim().length > 0) + + let currentBranch: string | undefined + let upstream: string | undefined + let ahead = 0 + let behind = 0 + let detached = false + const fileStatus: GitFileStatus[] = [] + + if (lines.length === 0) { + return { + currentBranch, + upstream, + ahead, + behind, + detached, + fileStatus, + isClean: true, + hasChanges: false, + hasStaged: false, + hasUntracked: false, + hasConflicts: false, + totalCount: 0, + stagedCount: 0, + unstagedCount: 0, + untrackedCount: 0, + conflictCount: 0, + } + } + + const branchLine = lines[0] + if (branchLine.startsWith('## ')) { + const branchInfo = branchLine.slice(3) + const aheadStart = branchInfo.indexOf(' [') + const branchPart = + aheadStart === -1 ? branchInfo : branchInfo.slice(0, aheadStart) + const aheadPart = + aheadStart === -1 ? undefined : branchInfo.slice(aheadStart + 2, -1) + const normalizedBranch = normalizeBranchName(branchPart) + const rawBranch = branchPart + const isDetached = + rawBranch.startsWith('HEAD (detached at ') || + rawBranch.includes('detached') + + if (isDetached || normalizedBranch.startsWith('HEAD')) { + detached = true + } else if (normalizedBranch.includes('...')) { + const [branch, upstreamBranch] = normalizedBranch.split('...') + currentBranch = branch || undefined + upstream = upstreamBranch || undefined + } else { + currentBranch = normalizedBranch || undefined + } + + const aheadBehind = parseAheadBehind(aheadPart) + ahead = aheadBehind.ahead + behind = aheadBehind.behind + } + + for (const line of lines.slice(1)) { + if (line.startsWith('?? ')) { + const name = line.slice(3) + fileStatus.push({ + name, + status: 'untracked', + indexStatus: '?', + workingTreeStatus: '?', + staged: false, + }) + continue + } + + if (line.length < 3) { + continue + } + + const indexStatus = line[0] + const workingTreeStatus = line[1] + const path = line.slice(3) + + let renamedFrom: string | undefined + let name = path + + if (path.includes(' -> ')) { + const parts = path.split(' -> ') + renamedFrom = parts[0] + name = parts.slice(1).join(' -> ') + } + + fileStatus.push({ + name, + status: deriveStatus(indexStatus, workingTreeStatus), + indexStatus, + workingTreeStatus, + staged: indexStatus !== ' ' && indexStatus !== '?', + ...(renamedFrom ? { renamedFrom } : {}), + }) + } + + const totalCount = fileStatus.length + const stagedCount = fileStatus.filter((item) => item.staged).length + const untrackedCount = fileStatus.filter( + (item) => item.status === 'untracked' + ).length + const conflictCount = fileStatus.filter( + (item) => item.status === 'conflict' + ).length + const unstagedCount = totalCount - stagedCount + + return { + currentBranch, + upstream, + ahead, + behind, + detached, + fileStatus, + isClean: totalCount === 0, + hasChanges: totalCount > 0, + hasStaged: stagedCount > 0, + hasUntracked: untrackedCount > 0, + hasConflicts: conflictCount > 0, + totalCount, + stagedCount, + unstagedCount, + untrackedCount, + conflictCount, + } +} + +/** + * Parse `git branch --format=%(refname:short)\t%(HEAD)` output. + * + * @param output Git branch output. + * @returns Parsed {@link GitBranches}. + */ +export function parseGitBranches(output: string): GitBranches { + const branches: string[] = [] + let currentBranch: string | undefined + + const lines = output + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + + for (const line of lines) { + const parts = line.split('\t') + const name = parts[0] + branches.push(name) + if (parts.length > 1 && parts[1] === '*') { + currentBranch = name + } + } + + return { branches, currentBranch } +} + +export function isAuthFailure(err: unknown): boolean { + if (!(err instanceof CommandExitError)) { + return false + } + + const message = `${err.stderr}\n${err.stdout}`.toLowerCase() + const authSnippets = [ + 'authentication failed', + 'terminal prompts disabled', + 'could not read username', + 'invalid username or password', + 'access denied', + 'permission denied', + 'not authorized', + ] + + return authSnippets.some((snippet) => message.includes(snippet)) +} + +export function getScopeFlag(scope: GitConfigScope): `--${GitConfigScope}` { + if (scope !== 'global' && scope !== 'local' && scope !== 'system') { + throw new InvalidArgumentError( + 'Git config scope must be one of: global, local, system.' + ) + } + return `--${scope}` +} + +export function isMissingUpstream(err: unknown): boolean { + if (!(err instanceof CommandExitError)) { + return false + } + + const message = `${err.stderr}\n${err.stdout}`.toLowerCase() + const upstreamSnippets = [ + 'has no upstream branch', + 'no upstream branch', + 'no upstream configured', + 'no tracking information for the current branch', + 'no tracking information', + 'set the remote as upstream', + 'set the upstream branch', + 'please specify which branch you want to merge with', + ] + + return upstreamSnippets.some((snippet) => message.includes(snippet)) +} + +export function buildAuthErrorMessage( + action: 'clone' | 'push' | 'pull', + missingPassword: boolean +): string { + if (missingPassword) { + return `Git ${action} requires a password/token for private repositories.` + } + return `Git ${action} requires credentials for private repositories.` +} + +export function buildUpstreamErrorMessage(action: 'push' | 'pull'): string { + if (action === 'push') { + return ( + 'Git push failed because no upstream branch is configured. ' + + 'Set upstream once with { setUpstream: true } (and optional remote/branch), ' + + 'or pass remote and branch explicitly.' + ) + } + + return ( + 'Git pull failed because no upstream branch is configured. ' + + 'Pass remote and branch explicitly, or set upstream once (push with { setUpstream: true } ' + + 'or run: git branch --set-upstream-to=origin/ ).' + ) +} + +export function getRepoPathForScope( + scope: GitConfigScope, + path?: string +): string | undefined { + if (scope !== 'local') { + return undefined + } + if (!path) { + throw new InvalidArgumentError( + 'A repository path is required when using scope "local".' + ) + } + return path +} diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts new file mode 100644 index 0000000..56caea6 --- /dev/null +++ b/packages/js-sdk/src/sandbox/index.ts @@ -0,0 +1,814 @@ +import { createConnectTransport } from '@connectrpc/connect-web' + +import { + ConnectionConfig, + ConnectionOpts, + DEFAULT_SANDBOX_TIMEOUT_MS, + defaultUsername, + Username, +} from '../connectionConfig' +import { EnvdApiClient, handleEnvdApiError } from '../envd/api' +import { createEnvdFetch, createEnvdRpcFetch } from '../envd/http2' +import { createRpcLogger } from '../logs' +import { Commands, Pty } from './commands' +import { Filesystem } from './filesystem' +import { Git } from './git' +import { + SandboxOpts, + SandboxConnectOpts, + SandboxMetricsOpts, + SandboxApi, + SandboxListOpts, + SandboxNetworkUpdate, + SandboxPaginator, + SnapshotListOpts, + SnapshotInfo, + SnapshotPaginator, + CreateSnapshotOpts, + SandboxPauseOpts, +} from './sandboxApi' +import { getSignature } from './signature' +import { compareVersions } from 'compare-versions' +import { InvalidArgumentError, TemplateError } from '../errors' +import { ENVD_DEBUG_FALLBACK, ENVD_DEFAULT_USER } from '../envd/versions' +import { shellQuote } from '../utils' + +/** + * Options for sandbox upload/download URL generation. + */ +export interface SandboxUrlOpts { + /** + * Use signature expiration for the URL. + * Optional parameter to set the expiration time for the signature in seconds. + */ + useSignatureExpiration?: number + + /** + * User that will be used to access the file. + */ + user?: Username +} + +/** + * E2B cloud sandbox is a secure and isolated cloud environment. + * + * The sandbox allows you to: + * - Access Linux OS + * - Create, list, and delete files and directories + * - Run commands + * - Run git operations + * - Run isolated code + * - Access the internet + * + * Check docs [here](https://e2b.dev/docs). + * + * Use {@link Sandbox.create} to create a new sandbox. + * + * @example + * ```ts + * import { Sandbox } from 'e2b' + * + * const sandbox = await Sandbox.create() + * ``` + */ +export class Sandbox extends SandboxApi { + protected static readonly defaultTemplate: string = 'base' + protected static readonly defaultMcpTemplate: string = 'mcp-gateway' + protected static readonly defaultSandboxTimeoutMs = DEFAULT_SANDBOX_TIMEOUT_MS + + /** + * Module for interacting with the sandbox filesystem + */ + readonly files: Filesystem + /** + * Module for running commands in the sandbox + */ + readonly commands: Commands + /** + * Module for interacting with the sandbox pseudo-terminals + */ + readonly pty: Pty + /** + * Module for running git operations in the sandbox + */ + readonly git: Git + + /** + * Unique identifier of the sandbox. + */ + readonly sandboxId: string + + /** + * Domain where the sandbox is hosted. + */ + readonly sandboxDomain: string + + /** + * Traffic access token for accessing sandbox services with restricted public traffic. + */ + readonly trafficAccessToken?: string + + protected readonly envdPort = 49983 + protected readonly mcpPort = 50005 + + protected readonly connectionConfig: ConnectionConfig + protected readonly envdAccessToken?: string + private readonly envdApiUrl: string + private readonly envdDirectUrl: string + private readonly envdApi: EnvdApiClient + private mcpToken?: string + + /** + * Use {@link Sandbox.create} to create a new Sandbox instead. + * + * @hidden + * @hide + * @internal + * @access protected + */ + constructor( + opts: SandboxConnectOpts & { + sandboxId: string + sandboxDomain?: string + envdVersion: string + envdAccessToken?: string + trafficAccessToken?: string + } + ) { + super() + + this.connectionConfig = new ConnectionConfig(opts) + + this.sandboxId = opts.sandboxId + this.sandboxDomain = opts.sandboxDomain ?? this.connectionConfig.domain + + this.envdAccessToken = opts.envdAccessToken + this.trafficAccessToken = opts.trafficAccessToken + this.envdApiUrl = this.connectionConfig.getSandboxUrl(this.sandboxId, { + sandboxDomain: this.sandboxDomain, + envdPort: this.envdPort, + }) + this.envdDirectUrl = this.connectionConfig.getSandboxDirectUrl( + this.sandboxId, + { + sandboxDomain: this.sandboxDomain, + envdPort: this.envdPort, + } + ) + + const sandboxHeaders = { + 'E2b-Sandbox-Id': this.sandboxId, + 'E2b-Sandbox-Port': this.envdPort.toString(), + } + const envdFetch = createEnvdFetch(this.connectionConfig.proxy) + const envdRpcFetch = createEnvdRpcFetch(this.connectionConfig.proxy) + + const rpcTransport = createConnectTransport({ + baseUrl: this.envdApiUrl, + useBinaryFormat: false, + interceptors: opts?.logger ? [createRpcLogger(opts.logger)] : undefined, + fetch: (url, options) => { + // Patch fetch to always use redirect: "follow" + // connect-web doesn't allow to configure redirect option - https://github.com/connectrpc/connect-es/pull/1082 + // connect-web package uses redirect: "error" which is not supported in edge runtimes + // E2B endpoints should be safe to use with redirect: "follow" https://github.com/e2b-dev/E2B/issues/531#issuecomment-2779492867 + + const headers = new Headers({ + 'User-Agent': this.connectionConfig.headers?.['User-Agent'] ?? '', + }) + new Headers(options?.headers).forEach((value, key) => + headers.append(key, value) + ) + new Headers(sandboxHeaders).forEach((value, key) => + headers.append(key, value) + ) + + if (this.envdAccessToken) { + headers.append('X-Access-Token', this.envdAccessToken) + } + + options = { + ...(options ?? {}), + headers: headers, + redirect: 'follow', + } + + return envdRpcFetch(url, options) + }, + }) + + this.envdApi = new EnvdApiClient( + { + apiUrl: this.envdApiUrl, + logger: opts?.logger, + envdAccessToken: this.envdAccessToken, + headers: { + 'User-Agent': this.connectionConfig.headers?.['User-Agent'] ?? '', + ...sandboxHeaders, + }, + fetch: (request) => envdFetch(request), + }, + { + version: opts.envdVersion, + } + ) + this.files = new Filesystem( + rpcTransport, + this.envdApi, + this.connectionConfig + ) + this.commands = new Commands( + rpcTransport, + this.envdApi, + this.connectionConfig + ) + this.pty = new Pty(rpcTransport, this.envdApi, this.connectionConfig) + this.git = new Git(this.commands) + } + + /** + * List sandboxes. + * + * By default (no `query.state` set in `opts`), returns sandboxes in both + * `running` and `paused` states. To filter by state, pass + * `opts.query.state = [...]`. + * + * @param opts connection options, plus optional `query` to filter by + * metadata / state and `limit` / `nextToken` for pagination. + * + * @returns a {@link SandboxPaginator} that yields pages of sandboxes + * (running and paused by default). Iterate pages via + * `await paginator.nextItems()` while `paginator.hasNext` is `true`. + */ + static list(opts?: SandboxListOpts): SandboxPaginator { + return new SandboxPaginator(opts) + } + + /** + * Create a new sandbox from the default `base` sandbox template. + * + * @param opts connection options. + * + * @returns sandbox instance for the new sandbox. + * + * @example + * ```ts + * const sandbox = await Sandbox.create() + * ``` + * @constructs {@link Sandbox} + */ + static async create( + this: S, + opts?: SandboxOpts + ): Promise> + + /** + * Create a new sandbox from the specified sandbox template. + * + * @param template sandbox template name or ID. + * @param opts connection options. + * + * @returns sandbox instance for the new sandbox. + * + * @example + * ```ts + * const sandbox = await Sandbox.create('') + * ``` + * @constructs {@link Sandbox} + */ + static async create( + this: S, + template: string, + opts?: SandboxOpts + ): Promise> + static async create( + this: S, + templateOrOpts?: SandboxOpts | string, + opts?: SandboxOpts + ): Promise> { + const { template, sandboxOpts } = + typeof templateOrOpts === 'string' + ? { + template: templateOrOpts, + sandboxOpts: opts, + } + : { + template: + templateOrOpts?.template ?? + (templateOrOpts?.mcp + ? this.defaultMcpTemplate + : this.defaultTemplate), + sandboxOpts: templateOrOpts, + } + + const config = new ConnectionConfig(sandboxOpts) + if (config.debug) { + return new this({ + sandboxId: 'debug_sandbox_id', + envdVersion: ENVD_DEBUG_FALLBACK, + ...config, + }) as InstanceType + } + + const sandboxInfo = await SandboxApi.createSandbox( + template, + sandboxOpts?.timeoutMs ?? this.defaultSandboxTimeoutMs, + sandboxOpts + ) + + const sandbox = new this({ ...sandboxInfo, ...config }) as InstanceType + + if (sandboxOpts?.mcp) { + sandbox.mcpToken = crypto.randomUUID() + const res = await sandbox.commands.run( + `mcp-gateway --config ${shellQuote(JSON.stringify(sandboxOpts.mcp))}`, + { + user: 'root', + envs: { + GATEWAY_ACCESS_TOKEN: sandbox.mcpToken ?? '', + }, + } + ) + if (res.exitCode !== 0) { + throw new Error(`Failed to start MCP gateway: ${res.stderr}`) + } + } + + return sandbox + } + + /** + * Connect to a sandbox. If the sandbox is paused, it will be automatically resumed. + * Sandbox must be either running or be paused. + * + * With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc). + * + * @param sandboxId sandbox ID. + * @param opts connection options. + * + * @returns A running sandbox instance + * + * @example + * ```ts + * const sandbox = await Sandbox.create() + * const sandboxId = sandbox.sandboxId + * + * // Connect to the same sandbox. + * const sameSandbox = await Sandbox.connect(sandboxId) + * ``` + */ + static async connect( + this: S, + sandboxId: string, + opts?: SandboxConnectOpts + ): Promise> { + const config = new ConnectionConfig(opts) + if (config.debug) { + return new this({ + sandboxId, + envdVersion: ENVD_DEBUG_FALLBACK, + ...config, + }) as InstanceType + } + + const sandbox = await SandboxApi.connectSandbox(sandboxId, opts) + + return new this({ + sandboxId, + sandboxDomain: sandbox.sandboxDomain, + envdAccessToken: sandbox.envdAccessToken, + trafficAccessToken: sandbox.trafficAccessToken, + envdVersion: sandbox.envdVersion, + ...config, + }) as InstanceType + } + + /** + * Connect to a sandbox. If the sandbox is paused, it will be automatically resumed. + * Sandbox must be either running or be paused. + * + * With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc). + * + * @param opts connection options. + * + * @returns A running sandbox instance + * + * @example + * ```ts + * const sandbox = await Sandbox.create() + * await sandbox.betaPause() + * + * // Connect to the same sandbox. + * const sameSandbox = await sandbox.connect() + * ``` + */ + async connect(opts?: SandboxConnectOpts): Promise { + if (this.connectionConfig.debug) { + // Skip connecting to the sandbox in debug mode + return this + } + + await SandboxApi.connectSandbox(this.sandboxId, this.resolveApiOpts(opts)) + + return this + } + + /** + * Get the host address for the specified sandbox port. + * You can then use this address to connect to the sandbox port from outside the sandbox via HTTP or WebSocket. + * + * @param port number of the port in the sandbox. + * + * @returns host address of the sandbox port. + * + * @example + * ```ts + * const sandbox = await Sandbox.create() + * // Start an HTTP server + * await sandbox.commands.exec('python3 -m http.server 3000') + * // Get the hostname of the HTTP server + * const serverURL = sandbox.getHost(3000) + * ``` + */ + getHost(port: number) { + return this.connectionConfig.getHost( + this.sandboxId, + port, + this.sandboxDomain + ) + } + + /** + * Check if the sandbox is running. + * + * @returns `true` if the sandbox is running, `false` otherwise. + * + * @example + * ```ts + * const sandbox = await Sandbox.create() + * await sandbox.isRunning() // Returns true + * + * await sandbox.kill() + * await sandbox.isRunning() // Returns false + * ``` + */ + async isRunning( + opts?: Pick + ): Promise { + const signal = this.connectionConfig.getSignal( + opts?.requestTimeoutMs, + opts?.signal + ) + + const res = await this.envdApi.api.GET('/health', { + signal, + }) + + if (res.response.status == 502) { + return false + } + + const err = await handleEnvdApiError(res) + if (err) { + throw err + } + + return true + } + + /** + * Set the timeout of the sandbox. + * + * This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.setTimeout`. + * Maximum time a sandbox can be kept alive is 24 hours (86_400_000 milliseconds) for Pro users and 1 hour (3_600_000 milliseconds) for Hobby users. + * + * @param timeoutMs timeout in **milliseconds**. + * @param opts connection options. + */ + async setTimeout( + timeoutMs: number, + opts?: Pick + ) { + if (this.connectionConfig.debug) { + // Skip timeout in debug mode + return + } + + await SandboxApi.setTimeout( + this.sandboxId, + timeoutMs, + this.resolveApiOpts(opts) + ) + } + + /** + * Update the network configuration of the sandbox. + * + * Replaces the current egress configuration atomically — fields that are + * omitted are cleared on the server. + * + * @param network new network configuration. + * @param opts connection options. + */ + async updateNetwork( + network: SandboxNetworkUpdate, + opts?: Pick + ) { + await SandboxApi.updateNetwork( + this.sandboxId, + network, + this.resolveApiOpts(opts) + ) + } + + /** + * Kill the sandbox. + * + * @param opts connection options. + * + * @returns `true` if the sandbox was killed, `false` if the sandbox was not found. + */ + async kill( + opts?: Pick + ): Promise { + if (this.connectionConfig.debug) { + // Skip killing the sandbox in debug mode + return true + } + + return await SandboxApi.kill(this.sandboxId, this.resolveApiOpts(opts)) + } + + /** + * Pause a sandbox by its ID. + * + * @param opts connection options, plus `keepMemory` to control the snapshot + * kind. When `opts.keepMemory` is `false`, the in-memory state is dropped and + * only the filesystem is persisted (a filesystem-only snapshot); resuming such + * a sandbox cold-boots (reboots) it from disk, losing running processes and + * open connections. Defaults to `true` (full memory snapshot). + * + * @returns `true` if the sandbox got paused, `false` if the sandbox was already paused. + * + * @example + * ```ts + * const sandbox = await Sandbox.create() + * await sandbox.pause() + * + * // filesystem-only snapshot (resume reboots the sandbox) + * await sandbox.pause({ keepMemory: false }) + * ``` + */ + async pause(opts?: SandboxPauseOpts): Promise { + return await SandboxApi.pause(this.sandboxId, this.resolveApiOpts(opts)) + } + + /** + * @deprecated Use {@link Sandbox.pause} instead. + */ + async betaPause(opts?: SandboxPauseOpts): Promise { + return await SandboxApi.betaPause(this.sandboxId, this.resolveApiOpts(opts)) + } + + /** + * Create a snapshot of the sandbox's current state. + * + * The sandbox will be paused while the snapshot is being created. + * The snapshot can be used to create new sandboxes with the same filesystem and state. + * Snapshots are persistent and survive sandbox deletion. + * + * Use the returned `snapshotId` with `Sandbox.create(snapshotId)` to create a new sandbox from the snapshot. + * + * @param opts snapshot creation options including optional name and connection options. + * + * @returns snapshot information including the snapshot ID. + * + * @example + * ```ts + * const sandbox = await Sandbox.create() + * await sandbox.files.write('/app/state.json', '{"step": 1}') + * + * // Create a snapshot + * const snapshot = await sandbox.createSnapshot({ name: 'my-snapshot' }) + * + * // Create a new sandbox from the snapshot + * const newSandbox = await Sandbox.create(snapshot.snapshotId) + * ``` + */ + async createSnapshot(opts?: CreateSnapshotOpts): Promise { + return await SandboxApi.createSnapshot(this.sandboxId, { + ...this.resolveApiOpts(opts), + name: opts?.name, + }) + } + + /** + * List all snapshots created from this sandbox. + * + * @param opts list options. + * + * @returns paginator for listing snapshots from this sandbox. + */ + listSnapshots(opts?: Omit): SnapshotPaginator { + return SandboxApi.listSnapshots({ + ...this.resolveApiOpts(opts), + sandboxId: this.sandboxId, + }) + } + + /** + * + * Get the MCP URL for the sandbox. + * + * @returns MCP URL for the sandbox. + */ + getMcpUrl(): string { + return `https://${this.getHost(this.mcpPort)}/mcp` + } + + /** + * Get the MCP token for the sandbox. + * + * @returns MCP token for the sandbox, or undefined if MCP is not enabled. + */ + async getMcpToken(): Promise { + if (!this.mcpToken) { + this.mcpToken = await this.files.read('/etc/mcp-gateway/.token', { + user: 'root', + }) + } + + return this.mcpToken + } + + /** + * Get the URL to upload a file to the sandbox. + * + * You have to send a POST request to this URL with the file as multipart/form-data. + * + * @param path path to the file in the sandbox. + * + * @param opts download url options. + * + * @returns URL for uploading file. + */ + async uploadUrl(path?: string, opts?: SandboxUrlOpts) { + opts = opts ?? {} + + const useSignature = !!this.envdAccessToken + + if (!useSignature && opts.useSignatureExpiration != undefined) { + throw new InvalidArgumentError( + 'Signature expiration can be used only when sandbox is created as secured.' + ) + } + + let username = opts.user + if ( + username == undefined && + compareVersions(this.envdApi.version, ENVD_DEFAULT_USER) < 0 + ) { + username = defaultUsername + } + + const filePath = path ?? '' + const fileUrl = this.fileUrl(filePath, username) + + if (useSignature) { + const url = new URL(fileUrl) + const sig = await getSignature({ + path: filePath, + operation: 'write', + user: username, + expirationInSeconds: opts.useSignatureExpiration, + envdAccessToken: this.envdAccessToken, + }) + + url.searchParams.set('signature', sig.signature) + if (sig.expiration) { + url.searchParams.set('signature_expiration', sig.expiration.toString()) + } + + return url.toString() + } + + return fileUrl + } + + /** + * Get the URL to download a file from the sandbox. + * + * @param path path to the file in the sandbox. + * + * @param opts download url options. + * + * @returns URL for downloading file. + */ + async downloadUrl(path: string, opts?: SandboxUrlOpts) { + opts = opts ?? {} + + const useSignature = !!this.envdAccessToken + + if (!useSignature && opts.useSignatureExpiration != undefined) { + throw new InvalidArgumentError( + 'Signature expiration can be used only when sandbox is created as secured.' + ) + } + + let username = opts.user + if ( + username == undefined && + compareVersions(this.envdApi.version, ENVD_DEFAULT_USER) < 0 + ) { + username = defaultUsername + } + + const fileUrl = this.fileUrl(path, username) + + if (useSignature) { + const url = new URL(fileUrl) + const sig = await getSignature({ + path, + operation: 'read', + user: username, + expirationInSeconds: opts.useSignatureExpiration, + envdAccessToken: this.envdAccessToken, + }) + + url.searchParams.set('signature', sig.signature) + if (sig.expiration) { + url.searchParams.set('signature_expiration', sig.expiration.toString()) + } + + return url.toString() + } + + return fileUrl + } + + /** + * Get sandbox information like sandbox ID, template, metadata, started at/end at date. + * + * @param opts connection options. + * + * @returns information about the sandbox + */ + async getInfo(opts?: Pick) { + return await SandboxApi.getInfo(this.sandboxId, this.resolveApiOpts(opts)) + } + + /** + * Get the metrics of the sandbox. + * + * @param opts connection options. + * + * @returns List of sandbox metrics containing CPU, memory and disk usage information. + */ + async getMetrics(opts?: SandboxMetricsOpts) { + if (this.connectionConfig.debug) { + // Skip getting the metrics in debug mode + return [] + } + + if (this.envdApi.version) { + if (compareVersions(this.envdApi.version, '0.1.5') < 0) { + throw new TemplateError( + 'You need to update the template to use the new SDK.' + ) + } + + if (compareVersions(this.envdApi.version, '0.2.4') < 0) { + this.connectionConfig.logger?.warn?.( + 'Disk metrics are not supported in this version of the sandbox, please rebuild the template to get disk metrics.' + ) + } + } + + return await SandboxApi.getMetrics( + this.sandboxId, + this.resolveApiOpts(opts) + ) + } + + private resolveApiOpts( + opts?: T + ): ConnectionOpts & T { + return { + ...this.connectionConfig, + ...(opts ?? {}), + } as ConnectionOpts & T + } + + private fileUrl(path: string | undefined, username: string | undefined) { + const url = new URL('/files', this.envdDirectUrl) + + if (username) { + url.searchParams.set('username', username) + } + if (path) { + url.searchParams.set('path', path) + } + + return url.toString() + } +} diff --git a/packages/js-sdk/src/sandbox/mcp.d.ts b/packages/js-sdk/src/sandbox/mcp.d.ts new file mode 100644 index 0000000..c05b64b --- /dev/null +++ b/packages/js-sdk/src/sandbox/mcp.d.ts @@ -0,0 +1,1835 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export interface McpServer { + airtable?: AirtableMCPServer + aks?: AzureKubernetesServiceAKS + apiGateway?: ApiGateway + apify?: ApifyMCPServer + arxiv?: ArXivMCPServer + astGrep?: AstGrep + astraDb?: AstraDB + astroDocs?: AstroDocs + atlan?: AtlanMCPServer + atlasDocs?: AtlasDocs + atlassian?: Atlassian + audienseInsights?: AudienseInsights + awsCdk?: AWSCDK + awsCore?: AWSCoreMCPServer + awsDiagram?: AWSDiagram + awsDocumentation?: AWSDocumentation + awsKbRetrievalServer?: AWSKBRetrievalArchived + awsTerraform?: AWSTerraform + azure?: Azure + beagleSecurity?: BeagleSecurityMCPServer + bitrefill?: Bitrefill + box?: Box + brave?: BraveSearch + browserbase?: Browserbase + buildkite?: Buildkite + camunda?: CamundaBPMProcessEngineMCPServer + cdataConnectcloud?: CDataConnectCloud + charmhealth?: CharmHealthMCPServer + chroma?: Chroma + circleci?: CircleCI + clickhouse?: OfficialClickHouseMCPServer + close?: Close + cloudRun?: CloudRunMCP + cloudflareDocs?: CloudflareDocs + cockroachdb?: CockroachDB + codeInterpreter?: PythonInterpreter + context7?: Context7 + couchbase?: Couchbase + cylera?: TheOfficialMCPServerForCylera + cyreslabAiShodan?: Shodan + dappier?: Dappier + dappierRemote?: DappierRemoteMCPServer + dart?: DartAI + databaseServer?: MCPDatabaseServer + databutton?: Databutton + deepwiki?: DeepWiki + descope?: Descope + desktopCommander?: DesktopCommander + devhubCms?: DevHubCMS + discord?: Discord + dockerhub?: DockerHub + dodoPayments?: DodoPayments + dreamfactory?: DreamFactoryMCPServer + duckduckgo?: DuckDuckGo + dynatrace?: DynatraceMCPServer + e2b?: E2B + edubase?: EduBase + effect?: EffectMCP + elasticsearch?: Elasticsearch + elevenlabs?: ElevenlabsMCP + everart?: EverArtArchived + exa?: Exa + explorium?: ExploriumB2BData + fetch?: FetchReference + fibery?: Fibery + filesystem?: FilesystemReference + findADomain?: FindADomain + firecrawl?: Firecrawl + firewalla?: FirewallaMCPServer + flexprice?: FlexPrice + git?: GitReference + github?: GitHubArchived + githubChat?: GitHubChat + githubOfficial?: GitHubOfficial + gitlab?: GitLabArchived + gitmcp?: GitMCP + glif?: GlifApp + gmail?: GmailMCPServer + googleMaps?: GoogleMapsArchived + googleMapsComprehensive?: GoogleMapsComprehensiveMCP + grafana?: Grafana + gyazo?: Gyazo + hackernews?: HackernewsMcp + hackle?: Hackle + handwritingOcr?: HandwritingOCR + hdx?: HumanitarianDataExchangeMCPServer + heroku?: Heroku + hostinger?: HostingerAPIMCPServer + hoverfly?: HoverflyMCPServer + hubspot?: HubSpot + huggingFace?: HuggingFace + hummingbot?: HummingbotMCPTradingAgent + husqvarnaAutomower?: HusqvarnaAutomower + hyperbrowser?: Hyperbrowser + hyperspell?: Hyperspell + iaptic?: Iaptic + inspektorGadget?: InspektorGadget + javadocs?: Javadocs + jetbrains?: JetBrains + kafkaSchemaReg?: KafkaSchemaRegistryMCP + kagisearch?: KagiSearch + keboola?: KeboolaMCPServer + kong?: KongKonnect + kubectl?: KubectlMCPServer + kubernetes?: Kubernetes + lara?: LaraTranslate + line?: LINE + linkedin?: LinkedInMCPServer + llmtxt?: LLMText + maestro?: MaestroMCPServer + manifold?: Manifold + mapbox?: MapboxMCPServer + mapboxDevkit?: MapboxDeveloperMCPServer + markdownify?: Markdownify + markitdown?: Markitdown + mavenTools?: MavenToolsMCPServer + memory?: MemoryReference + mercadoLibre?: MercadoLibre + mercadoPago?: MercadoPago + metabase?: MetabaseMCP + minecraftWiki?: MinecraftWiki + mongodb?: MongoDB + multiversxMx?: MultiversX + nasdaqDataLink?: NasdaqDataLink + needle?: Needle + neo4jCloudAuraApi?: Neo4JCloudAuraApi + neo4jCypher?: Neo4JCypher + neo4jDataModeling?: Neo4JDataModeling + neo4jMemory?: Neo4JMemory + neon?: Neon + nodeCodeSandbox?: NodeJsSandbox + notion?: Notion + novita?: Novita + npmSentinel?: NPMSentinel + obsidian?: Obsidian + oktaMcpFctr?: OktaMCPServer + omi?: OmiMcp + onlyofficeDocspace?: ONLYOFFICEDocSpace + openapi?: OpenAPIToolkitForMCP + openapiSchema?: OpenAPISchema + openbnbAirbnb?: AirbnbSearch + openmesh?: OpenMesh + openweather?: Openweather + openzeppelinCairo?: OpenZeppelinCairoContracts + openzeppelinSolidity?: OpenZeppelinSolidityContracts + openzeppelinStellar?: OpenZeppelinStellarContracts + openzeppelinStylus?: OpenZeppelinStylusContracts + opik?: Opik + opine?: OpineMCPServer + oracle?: OracleDatabaseMCPServer + ospMarketingTools?: OSPMarketingTools + oxylabs?: Oxylabs + paperSearch?: PaperSearch + perplexityAsk?: Perplexity + pia?: ProgramIntegrityAlliance + pinecone?: PineconeAssistant + playwright?: ExecuteAutomationPlaywrightMCP + pluggedinMcpProxy?: PluggedInMCPProxy + polarSignals?: PolarSignals + pomodash?: PomoDash + postgres?: PostgreSQLReadonlyArchived + postman?: PostmanMCPServer + prefEditor?: PrefEditor + prometheus?: Prometheus + puppeteer?: PuppeteerArchived + pythonRefactoring?: PythonRefactoringAssistant + quantconnect?: QuantConnectMCPServer + ramparts?: RampartsMCPSecurityScanner + razorpay?: Razorpay + reddit?: McpReddit + redis?: Redis + redisCloud?: RedisCloud + ref?: RefUpToDateDocs + remote?: RemoteMCP + render?: Render + resend?: SendEmails + risken?: RISKEN + root?: RootIoVulnerabilityRemediationMCP + ros2?: WiseVisionROS2MCPServer + rube?: Rube + rustMcpFilesystem?: BlazingFastAsynchronousMCPServerForSeamlessFilesystemOperations + schemacrawlerAi?: SchemaCrawlerAI + schoginiMcpImageBorder?: SchoginiMCPImageBorder + scrapegraph?: ScrapeGraph + scrapezy?: Scrapezy + securenoteLink?: SecurenoteLinkMcpServer + semgrep?: Semgrep + sentry?: SentryArchived + sequa?: SequaAI + sequentialthinking?: SequentialThinkingReference + shortIo?: ShortIo + simplechecklist?: SimpleCheckListMCPServer + singlestore?: Singlestore + slack?: SlackArchived + smartbear?: SmartBearMCPServer + sonarqube?: SonarQube + sqlite?: SQLiteArchived + stackgen?: StackGen + stackhawk?: StackHawk + stripe?: Stripe + supadata?: Supadata + suzieq?: SuzieqMCP + taskOrchestrator?: TaskOrchestrator + tavily?: Tavily + teamwork?: Teamwork + telnyx?: Telnyx + tembo?: Tembo + terraform?: HashicorpTerraform + textToGraphql?: TextToGraphQL + tigris?: TigrisData + time?: TimeReference + triplewhale?: Triplewhale + unrealEngine?: UnrealEngineMCPServer + veyrax?: VeyraX + vizro?: Vizro + vulnNist?: VulnNistMcpServer + wayfound?: WayfoundMCP + webflow?: Webflow + wikipedia?: Wikipedia + wolframAlpha?: WolframAlpha + youtubeTranscript?: YouTubeTranscripts + zerodhaKite?: ZerodhaKiteConnect +} +/** + * Provides AI assistants with direct access to Airtable bases, allowing them to read schemas, query records, and interact with your Airtable data. Supports listing bases, retrieving table structures, and searching through records to help automate workflows and answer questions about your organized data. + */ +export interface AirtableMCPServer { + airtableApiKey: string + nodeenv: string +} +/** + * Azure Kubernetes Service (AKS) official MCP server. + */ +export interface AzureKubernetesServiceAKS { + /** + * Access level for the MCP server, One of [ readonly, readwrite, admin ] + */ + accessLevel: string + /** + * Comma-separated list of additional tools, One of [ helm, cilium ] + */ + additionalTools?: string + /** + * Comma-separated list of namespaces to allow access to. If not specified, all namespaces are allowed. + */ + allowNamespaces?: string + /** + * Path to the Azure configuration directory (e.g. /home/azureuser/.azure). Used for Azure CLI authentication, you should be logged in (e.g. run `az login`) on the host before starting the MCP server. + */ + azureDir: string + /** + * Username or UID of the container user (format [:] e.g. 10000), ensuring correct permissions to access the Azure and kubeconfig files. Leave empty to use default user in the container. + */ + containerUser?: string + /** + * Path to the kubeconfig file for the AKS cluster (e.g. /home/azureuser/.kube/config). Used to connect to the AKS cluster. + */ + kubeconfig: string +} +/** + * A universal MCP (Model Context Protocol) server to integrate any API with Claude Desktop using only Docker configurations. + */ +export interface ApiGateway { + api1HeaderAuthorization: string + api1Name: string + api1SwaggerUrl: string +} +/** + * Apify is the world's largest marketplace of tools for web scraping, data extraction, and web automation. You can extract structured data from social media, e-commerce, search engines, maps, travel sites, or any other website. + */ +export interface ApifyMCPServer { + apifyToken: string + /** + * Comma-separated list of tools to enable. Can be either a tool category, a specific tool, or an Apify Actor. For example: "actors,docs,apify/rag-web-browser". For more details visit https://mcp.apify.com. + */ + tools: string +} +/** + * The ArXiv MCP Server provides a comprehensive bridge between AI assistants and arXiv's research repository through the Model Context Protocol (MCP). Features: • Search arXiv papers with advanced filtering • Download and store papers locally as markdown • Read and analyze paper content • Deep research analysis prompts • Local paper management and storage • Enhanced tool descriptions optimized for local AI models • Docker MCP Gateway compatible with detailed context Perfect for researchers, academics, and AI assistants conducting literature reviews and research analysis. **Recent Update**: Enhanced tool descriptions specifically designed to resolve local AI model confusion and improve Docker MCP Gateway compatibility. + */ +export interface ArXivMCPServer { + /** + * Directory path where downloaded papers will be stored + */ + storagePath: string +} +/** + * ast-grep is a fast and polyglot tool for code structural search, lint, rewriting at large scale. + */ +export interface AstGrep { + path: string +} +/** + * An MCP server for Astra DB workloads. + */ +export interface AstraDB { + astraDbApplicationToken: string + endpoint: string +} +/** + * Access the latest Astro web framework documentation, guides, and API references. + */ +export interface AstroDocs {} +/** + * MCP server for interacting with Atlan services including asset search, updates, and lineage traversal for comprehensive data governance and discovery. + */ +export interface AtlanMCPServer { + apiKey: string + baseUrl: string +} +/** + * Provide LLMs hosted, clean markdown documentation of libraries and frameworks. + */ +export interface AtlasDocs { + apiUrl: string +} +/** + * Tools for Atlassian products (Confluence and Jira). This integration supports both Atlassian Cloud and Jira Server/Data Center deployments. + */ +export interface Atlassian { + confluenceApiToken?: string + confluencePersonalToken?: string + confluenceUrl: string + confluenceUsername?: string + jiraApiToken?: string + jiraPersonalToken?: string + jiraUrl: string + jiraUsername?: string +} +/** + * Audiense Insights MCP Server is a server based on the Model Context Protocol (MCP) that allows Claude and other MCP-compatible clients to interact with your Audiense Insights account. + */ +export interface AudienseInsights { + audienseClientSecret?: string + clientId: string + twitterBearerToken?: string +} +/** + * AWS Cloud Development Kit (CDK) best practices, infrastructure as code patterns, and security compliance with CDK Nag. + */ +export interface AWSCDK {} +/** + * Starting point for using the awslabs MCP servers. + */ +export interface AWSCoreMCPServer {} +/** + * Seamlessly create diagrams using the Python diagrams package DSL. This server allows you to generate AWS diagrams, sequence diagrams, flow diagrams, and class diagrams using Python code. + */ +export interface AWSDiagram {} +/** + * Tools to access AWS documentation, search for content, and get recommendations. + */ +export interface AWSDocumentation {} +/** + * An MCP server implementation for retrieving information from the AWS Knowledge Base using the Bedrock Agent Runtime. + */ +export interface AWSKBRetrievalArchived { + accessKeyId: string + awsSecretAccessKey?: string +} +/** + * Terraform on AWS best practices, infrastructure as code patterns, and security compliance with Checkov. + */ +export interface AWSTerraform {} +/** + * The Azure MCP Server, bringing the power of Azure to your agents. + */ +export interface Azure {} +/** + * Connects with the Beagle Security backend using a user token to manage applications, run automated security tests, track vulnerabilities across environments, and gain intelligence from Application and API vulnerability data. + */ +export interface BeagleSecurityMCPServer { + beagleSecurityApiToken: string +} +/** + * A Model Context Protocol Server connector for Bitrefill public API, to enable AI agents to search and shop on Bitrefill. + */ +export interface Bitrefill { + apiId: string + apiSecret?: string +} +/** + * An MCP server capable of interacting with the Box API. + */ +export interface Box { + clientId: string + clientSecret?: string +} +/** + * Search the Web for pages, images, news, videos, and more using the Brave Search API. + */ +export interface BraveSearch { + apiKey: string +} +/** + * Allow LLMs to control a browser with Browserbase and Stagehand for AI-powered web automation, intelligent data extraction, and screenshot capture. + */ +export interface Browserbase { + apiKey: string + geminiApiKey: string + projectId: string +} +/** + * Buildkite MCP lets agents interact with Buildkite Builds, Jobs, Logs, Packages and Test Suites. + */ +export interface Buildkite { + apiToken: string +} +/** + * Tools to interact with the Camunda 7 Community Edition Engine using the Model Context Protocol (MCP). Whether you're automating workflows, querying process instances, or integrating with external systems, Camunda MCP Server is your agentic solution for seamless interaction with Camunda. + */ +export interface CamundaBPMProcessEngineMCPServer { + camundahost: string +} +/** + * This fully functional MCP Server allows you to connect to any data source in Connect Cloud from Claude Desktop. + */ +export interface CDataConnectCloud { + cdataPat?: string + username: string +} +/** + * An MCP server for CharmHealth EHR that allows LLMs and MCP clients to interact with patient records, encounters, and practice information. + */ +export interface CharmHealthMCPServer { + charmhealthApiKey: string + charmhealthBaseUrl: string + charmhealthClientId: string + charmhealthClientSecret: string + charmhealthRedirectUri: string + charmhealthRefreshToken: string + charmhealthTokenUrl: string +} +/** + * A Model Context Protocol (MCP) server implementation that provides database capabilities for Chroma. + */ +export interface Chroma { + apiKey: string +} +/** + * A specialized server implementation for the Model Context Protocol (MCP) designed to integrate with CircleCI's development workflow. This project serves as a bridge between CircleCI's infrastructure and the Model Context Protocol, enabling enhanced AI-powered development experiences. + */ +export interface CircleCI { + token: string + url: string +} +/** + * Official ClickHouse MCP Server. + */ +export interface OfficialClickHouseMCPServer { + connectTimeout: string + host: string + password: string + port: string + secure: string + sendReceiveTimeout: string + user: string + verify: string +} +/** + * Streamline sales processes with integrated calling, email, SMS, and automated workflows for small and scaling businesses. + */ +export interface Close { + apiKey: string +} +/** + * MCP server to deploy apps to Cloud Run. + */ +export interface CloudRunMCP { + /** + * path to application-default credentials (eg $HOME/.config/gcloud/application_default_credentials.json ) + */ + credentialsPath: string +} +/** + * Access the latest documentation on Cloudflare products such as Workers, Pages, R2, D1, KV. + */ +export interface CloudflareDocs {} +/** + * Enable AI agents to manage, monitor, and query CockroachDB using natural language. Perform complex database operations, cluster management, and query execution seamlessly through AI-driven workflows. Integrate effortlessly with MCP clients for scalable and high-performance data operations. + */ +export interface CockroachDB { + caPath: string + crdbPwd: string + database: string + host: string + port: number + sslCertfile: string + sslKeyfile: string + sslMode: string + username: string +} +/** + * A Python-based execution tool that mimics a Jupyter notebook environment. It accepts code snippets, executes them, and maintains state across sessions — preserving variables, imports, and past results. Ideal for iterative development, debugging, or code execution. + */ +export interface PythonInterpreter {} +/** + * Context7 MCP Server -- Up-to-date code documentation for LLMs and AI code editors. + */ +export interface Context7 {} +/** + * Couchbase is a distributed document database with a powerful search engine and in-built operational and analytical capabilities. + */ +export interface Couchbase { + /** + * Bucket in the Couchbase cluster to use for the MCP server. + */ + cbBucketName: string + /** + * Connection string for the Couchbase cluster. + */ + cbConnectionString: string + /** + * Setting to "true" (default) enables read-only query mode while running SQL++ queries. + */ + cbMcpReadOnlyQueryMode: string + cbPassword: string + /** + * Username for the Couchbase cluster with access to the bucket. + */ + cbUsername: string +} +/** + * Brings context about device inventory, threats, risks and utilization powered by the Cylera Partner API into an LLM. + */ +export interface TheOfficialMCPServerForCylera { + cyleraBaseUrl: string + cyleraPassword: string + cyleraUsername: string +} +/** + * A Model Context Protocol server that provides access to Shodan API functionality. + */ +export interface Shodan { + shodanApiKey: string +} +/** + * Enable fast, free real-time web search and access premium data from trusted media brands—news, financial markets, sports, entertainment, weather, and more. Build powerful AI agents with Dappier. + */ +export interface Dappier { + apiKey: string +} +/** + * Enable fast, free real-time web search and access premium data from trusted media brands—news, financial markets, sports, entertainment, weather, and more. Build powerful AI agents with Dappier. + */ +export interface DappierRemoteMCPServer { + dappierRemoteApiKey: string +} +/** + * Dart AI Model Context Protocol (MCP) server. + */ +export interface DartAI { + host: string + token: string +} +/** + * Comprehensive database server supporting PostgreSQL, MySQL, and SQLite with natural language SQL query capabilities. Enables AI agents to interact with databases through both direct SQL and natural language queries. + */ +export interface MCPDatabaseServer { + /** + * Connection string for your database. Examples: SQLite: sqlite+aiosqlite:///data/mydb.db, PostgreSQL: postgresql+asyncpg://user:password@localhost:5432/mydb, MySQL: mysql+aiomysql://user:password@localhost:3306/mydb + */ + databaseUrl: string +} +/** + * Databutton MCP Server. + */ +export interface Databutton {} +/** + * Tools for fetching and asking questions about GitHub repositories. + */ +export interface DeepWiki {} +/** + * The Descope Model Context Protocol (MCP) server provides an interface to interact with Descope's Management APIs, enabling the search and retrieval of project-related information. + */ +export interface Descope { + managementKey?: string + projectId: string +} +/** + * Search, update, manage files and run terminal commands with AI. + */ +export interface DesktopCommander { + /** + * List of directories that Desktop Commander can access + */ + paths: string[] +} +/** + * DevHub CMS LLM integration through the Model Context Protocol. + */ +export interface DevHubCMS { + devhubApiKey?: string + devhubApiSecret?: string + url: string +} +/** + * Interact with the Discord platform. + */ +export interface Discord { + discordToken: string +} +/** + * Docker Hub official MCP server. + */ +export interface DockerHub { + hubPatToken: string + username: string +} +/** + * Tools for cross-border payments, taxes, and compliance. + */ +export interface DodoPayments { + dodoPaymentsApiKey: string +} +/** + * DreamFactory is a REST API generation platform with support for hundreds of data sources, including Microsoft SQL Server, MySQL, PostgreSQL, and MongoDB. The DreamFactory MCP Server makes it easy for users to securely interact with their data sources via an MCP client. + */ +export interface DreamFactoryMCPServer { + dreamfactoryapikey: string + dreamfactoryurl: string +} +/** + * A Model Context Protocol (MCP) server that provides web search capabilities through DuckDuckGo, with additional features for content fetching and parsing. + */ +export interface DuckDuckGo {} +/** + * This MCP Server allows interaction with the Dynatrace observability platform, brining real-time observability data directly into your development workflow. + */ +export interface DynatraceMCPServer { + oauthClientId: string + oauthClientSecret: string + url: string +} +/** + * Giving Claude ability to run code with E2B via MCP (Model Context Protocol). + */ +export interface E2B { + apiKey: string +} +/** + * The EduBase MCP server enables Claude and other LLMs to interact with EduBase's comprehensive e-learning platform through the Model Context Protocol (MCP). + */ +export interface EduBase { + apiKey?: string + app: string + url: string +} +/** + * Tools and resources for writing Effect code in Typescript. + */ +export interface EffectMCP {} +/** + * Interact with your Elasticsearch indices through natural language conversations. + */ +export interface Elasticsearch { + esApiKey?: string + url: string +} +/** + * Official ElevenLabs Model Context Protocol (MCP) server that enables interaction with powerful Text to Speech and audio processing APIs. + */ +export interface ElevenlabsMCP { + apiKey?: string + data: string +} +/** + * Image generation server using EverArt's API. + */ +export interface EverArtArchived { + apiKey: string +} +/** + * Exa MCP for web search and web crawling!. + */ +export interface Exa { + apiKey: string +} +/** + * Discover companies, contacts, and business insights—powered by dozens of trusted external data sources. + */ +export interface ExploriumB2BData { + apiAccessToken: string +} +/** + * Fetches a URL from the internet and extracts its contents as markdown. + */ +export interface FetchReference {} +/** + * Interact with your Fibery workspace. + */ +export interface Fibery { + apiToken: string + host: string +} +/** + * Local filesystem access with configurable allowed paths. + */ +export interface FilesystemReference { + paths: string[] +} +/** + * Tools for finding domain names. + */ +export interface FindADomain {} +/** + * 🔥 Official Firecrawl MCP Server - Adds powerful web scraping and search to Cursor, Claude and any other LLM clients. + */ +export interface Firecrawl { + apiKey: string + creditCriticalThreshold: number + creditWarningThreshold: number + retryBackoffFactor: number + retryDelay: number + retryMax: number + retryMaxDelay: number + url: string +} +/** + * Real-time network monitoring, security analysis, and firewall management through 28 specialized tools. Access security alerts, network flows, device status, and firewall rules directly from your Firewalla device. + */ +export interface FirewallaMCPServer { + /** + * Your Firewalla Box Global ID + */ + boxId: string + firewallaMspToken: string + /** + * Your Firewalla MSP domain (e.g., yourdomain.firewalla.net) + */ + mspId: string +} +/** + * Official flexprice MCP Server. + */ +export interface FlexPrice { + apiKey: string + baseUrl: string +} +/** + * Git repository interaction and automation. + */ +export interface GitReference { + paths: string[] +} +/** + * Tools for interacting with the GitHub API, enabling file operations, repository management, search functionality, and more. + */ +export interface GitHubArchived { + personalAccessToken: string +} +/** + * A Model Context Protocol (MCP) for analyzing and querying GitHub repositories using the GitHub Chat API. + */ +export interface GitHubChat { + githubApiKey: string +} +/** + * Official GitHub MCP Server, by GitHub. Provides seamless integration with GitHub APIs, enabling advanced automation and interaction capabilities for developers and tools. + */ +export interface GitHubOfficial { + githubPersonalAccessToken: string +} +/** + * MCP Server for the GitLab API, enabling project management, file operations, and more. + */ +export interface GitLabArchived { + personalAccessToken: string + /** + * api url - optional for self-hosted instances + */ + url: string +} +/** + * Tools for interacting with Git repositories. + */ +export interface GitMCP {} +/** + * Easily run glif.app AI workflows inside your LLM: image generators, memes, selfies, and more. Glif supports all major multimedia AI models inside one app. + */ +export interface GlifApp { + apiToken: string + ids: string + ignoredSaved: boolean +} +/** + * A Model Context Protocol server for Gmail operations using IMAP/SMTP with app password authentication. Supports listing messages, searching emails, and sending messages. To create your app password, visit your Google Account settings under Security > App Passwords. Or visit the link https://myaccount.google.com/apppasswords. + */ +export interface GmailMCPServer { + /** + * Your Gmail email address + */ + emailAddress: string + emailPassword?: string +} +/** + * Tools for interacting with the Google Maps API. + */ +export interface GoogleMapsArchived { + googleMapsApiKey: string +} +/** + * Complete Google Maps integration with 8 tools including geocoding, places search, directions, elevation data, and more using Google's latest APIs. + */ +export interface GoogleMapsComprehensiveMCP { + googleMapsApiKey: string +} +/** + * MCP server for Grafana. + */ +export interface Grafana { + apiKey: string + url: string +} +/** + * Official Model Context Protocol server for Gyazo. + */ +export interface Gyazo { + accessToken: string +} +/** + * A Model Context Protocol (MCP) server that provides access to Hacker News stories, comments, and user data, with support for search and content retrieval. + */ +export interface HackernewsMcp {} +/** + * Model Context Protocol server for Hackle. + */ +export interface Hackle { + apiKey: string +} +/** + * Model Context Protocol (MCP) Server for Handwriting OCR. + */ +export interface HandwritingOCR { + apiToken: string +} +/** + * HDX MCP Server provides access to humanitarian data through the Humanitarian Data Exchange (HDX) API - https://data.humdata.org/hapi. This server offers 33 specialized tools for retrieving humanitarian information including affected populations (refugees, IDPs, returnees), baseline demographics, food security indicators, conflict data, funding information, and operational presence across hundreds of countries and territories. See repository for instructions on getting a free HDX_APP_INDENTIFIER for access. + */ +export interface HumanitarianDataExchangeMCPServer { + appIdentifier: string +} +/** + * Heroku Platform MCP Server using the Heroku CLI. + */ +export interface Heroku { + apiKey: string +} +/** + * Interact with Hostinger services over the Hostinger API. + */ +export interface HostingerAPIMCPServer { + apitoken: string +} +/** + * A Model Context Protocol (MCP) server that exposes Hoverfly as a programmable tool for AI assistants like Cursor, Claude, GitHub Copilot, and others supporting MCP. It enables dynamic mocking of third-party APIs to unblock development, automate testing, and simulate unavailable services during integration. + */ +export interface HoverflyMCPServer { + data: string +} +/** + * Unite marketing, sales, and customer service with AI-powered automation, lead management, and comprehensive analytics. + */ +export interface HubSpot { + apiKey: string +} +/** + * Tools for interacting with Hugging Face models, datasets, research papers, and more. + */ +export interface HuggingFace {} +/** + * Hummingbot MCP is an open-source toolset that lets you control and monitor your Hummingbot trading bots through AI-powered commands and automation. + */ +export interface HummingbotMCPTradingAgent { + apiUrl: string + hummingbotApiPassword?: string + hummingbotApiUsername?: string +} +/** + * MCP Server for huqsvarna automower. + */ +export interface HusqvarnaAutomower { + clientId: string + husqvarnaClientSecret: string +} +/** + * A MCP server implementation for hyperbrowser. + */ +export interface Hyperbrowser { + apiKey: string +} +/** + * Hyperspell MCP Server. + */ +export interface Hyperspell { + collection: string + token: string + useResources: boolean +} +/** + * Model Context Protocol server for interacting with iaptic. + */ +export interface Iaptic { + apiKey?: string + appName: string +} +/** + * AI interface to troubleshoot and observe Kubernetes/Container workloads. + */ +export interface InspektorGadget { + /** + * Comma-separated list of gadget images (trace_dns, trace_tcp, etc) to use, allowing control over which gadgets are available as MCP tools + */ + gadgetImages?: string + /** + * Path to the kubeconfig file for accessing Kubernetes clusters + */ + kubeconfig: string +} +/** + * Access to Java, Kotlin, and Scala library documentation. + */ +export interface Javadocs {} +/** + * A model context protocol server to work with JetBrains IDEs: IntelliJ, PyCharm, WebStorm, etc. Also, works with Android Studio. + */ +export interface JetBrains { + port: number +} +/** + * Comprehensive MCP server for Kafka Schema Registry operations. Features multi-registry support, schema contexts, migration tools, OAuth authentication, and 57+ tools for complete schema management. Supports SLIM_MODE for optimal performance. + */ +export interface KafkaSchemaRegistryMCP { + /** + * Schema Registry URL + */ + registryUrl: string + schemaRegistryPassword?: string + schemaRegistryUser?: string + /** + * Enable SLIM_MODE for better performance + */ + slimMode?: string + /** + * Enable read-only mode + */ + viewonly?: string +} +/** + * The Official Model Context Protocol (MCP) server for Kagi search & other tools. + */ +export interface KagiSearch { + engine: string + kagiApiKey: string +} +/** + * Keboola MCP Server is an open-source bridge between your Keboola project and modern AI tools. + */ +export interface KeboolaMCPServer { + kbcStorageToken: string + kbcWorkspaceSchema: string +} +/** + * A Model Context Protocol (MCP) server for interacting with Kong Konnect APIs, allowing AI assistants to query and analyze Kong Gateway configurations, traffic, and analytics. + */ +export interface KongKonnect { + konnectAccessToken: string + region: string +} +/** + * MCP Server that enables AI assistants to interact with Kubernetes clusters via kubectl operations. + */ +export interface KubectlMCPServer { + kubeconfig: string +} +/** + * Connect to a Kubernetes cluster and manage it. + */ +export interface Kubernetes { + /** + * the path to the host .kube/config + */ + configPath: string +} +/** + * Connect to Lara Translate API, enabling powerful translation capabilities with support for language detection and context-aware translations. + */ +export interface LaraTranslate { + accessKeySecret?: string + keyId: string +} +/** + * MCP server that integrates the LINE Messaging API to connect an AI Agent to the LINE Official Account. + */ +export interface LINE { + channelAccessToken?: string + userId: string +} +/** + * This MCP server allows Claude and other AI assistants to access your LinkedIn. Scrape LinkedIn profiles and companies, get your recommended jobs, and perform job searches. Set your li_at LinkedIn cookie to use this server. + */ +export interface LinkedInMCPServer { + linkedinCookie: string + /** + * Custom user agent string (optional, helps avoid detection and cookie login issues) + */ + userAgent: string +} +/** + * Discovers and retrieves llms.txt from websites. + */ +export interface LLMText {} +/** + * A Model Context Protocol (MCP) server exposing Bitcoin blockchain data through the Maestro API platform. Provides tools to explore blocks, transactions, addresses, inscriptions, runes, and other metaprotocol data. + */ +export interface MaestroMCPServer { + apiKeyApiKey: string +} +/** + * Tools for accessing the Manifold Markets online prediction market platform. + */ +export interface Manifold {} +/** + * Transform any AI agent into a geospatially-aware system with Mapbox APIs. Provides geocoding, POI search, routing, travel time matrices, isochrones, and static map generation. + */ +export interface MapboxMCPServer { + accessToken: string +} +/** + * Direct access to Mapbox developer APIs for AI assistants. Enables style management, token management, GeoJSON preview, and other developer tools for building Mapbox applications. + */ +export interface MapboxDeveloperMCPServer { + mapboxAccessToken: string +} +/** + * A Model Context Protocol server for converting almost anything to Markdown. + */ +export interface Markdownify { + paths: string[] +} +/** + * A lightweight MCP server for calling MarkItDown. + */ +export interface Markitdown { + paths: string[] +} +/** + * JVM dependency intelligence for any build tool using Maven Central Repository. Includes Context7 integration for upgrade documentation and guidance. + */ +export interface MavenToolsMCPServer {} +/** + * Knowledge graph-based persistent memory system. + */ +export interface MemoryReference {} +/** + * Provides access to Mercado Libre E-Commerce API. + */ +export interface MercadoLibre { + mercadoLibreApiKey: string +} +/** + * Provides access to Mercado Pago Marketplace API. + */ +export interface MercadoPago { + mercadoPagoApiKey: string +} +/** + * A comprehensive MCP server for Metabase with 70+ tools. + */ +export interface MetabaseMCP { + apiKey: string + metabaseurl: string + metabaseusername: string + password: string +} +/** + * A MCP Server for browsing the official Minecraft Wiki!. + */ +export interface MinecraftWiki {} +/** + * A Model Context Protocol server to connect to MongoDB databases and MongoDB Atlas Clusters. + */ +export interface MongoDB { + mdbMcpConnectionString: string +} +/** + * MCP Server for MultiversX. + */ +export interface MultiversX { + network: string + wallet: string +} +/** + * MCP server to interact with the data feeds provided by the Nasdaq Data Link. Developed by the community and maintained by Stefano Amorelli. + */ +export interface NasdaqDataLink { + nasdaqDataLinkApiKey: string +} +/** + * Production-ready RAG service to search and retrieve data from your documents. + */ +export interface Needle { + needleApiKey: string +} +/** + * Manage Neo4j Aura database instances through the Neo4j Aura API. + */ +export interface Neo4JCloudAuraApi { + clientId: string + neo4jAuraClientSecret?: string + serverAllowOrigins?: string + serverAllowedHosts?: string + serverHost?: string + serverPath?: string + serverPort?: string + transport?: string +} +/** + * Interact with Neo4j using Cypher graph queries. + */ +export interface Neo4JCypher { + database?: string + namespace?: string + neo4jPassword?: string + readOnly?: boolean + readTimeout?: string + responseTokenLimit?: string + serverAllowOrigins?: string + serverAllowedHosts?: string + serverHost?: string + serverPath?: string + serverPort?: string + transport?: string + url: string + username: string +} +/** + * MCP server that assists in creating, validating and visualizing graph data models. + */ +export interface Neo4JDataModeling { + serverAllowOrigins: string + serverAllowedHosts: string + serverHost: string + serverPath: string + serverPort: string + transport: string +} +/** + * Provide persistent memory capabilities through Neo4j graph database integration. + */ +export interface Neo4JMemory { + database?: string + neo4jPassword?: string + serverAllowOrigins?: string + serverAllowedHosts?: string + serverHost?: string + serverPath?: string + serverPort?: string + transport?: string + url: string + username: string +} +/** + * MCP server for interacting with Neon Management API and databases. + */ +export interface Neon { + apiKey: string +} +/** + * A Node.js–based Model Context Protocol server that spins up disposable Docker containers to execute arbitrary JavaScript. + */ +export interface NodeJsSandbox {} +/** + * Official Notion MCP Server. + */ +export interface Notion { + internalIntegrationToken: string +} +/** + * Seamless interaction with Novita AI platform resources. + */ +export interface Novita {} +/** + * MCP server that enables intelligent NPM package analysis powered by AI. + */ +export interface NPMSentinel {} +/** + * MCP server that interacts with Obsidian via the Obsidian rest API community plugin. + */ +export interface Obsidian { + apiKey: string +} +/** + * Secure Okta identity and access management via Model Context Protocol (MCP). Access Okta users, groups, applications, logs, and policies through AI assistants with enterprise-grade security. + */ +export interface OktaMCPServer { + /** + * Okta organization URL (e.g., https://dev-123456.okta.com) + */ + clientOrgurl: string + /** + * Maximum concurrent requests to Okta API + */ + concurrentLimit?: string + /** + * Logging level for server output + */ + logLevel?: string + oktaApiToken?: string +} +/** + * A Model Context Protocol server for Omi interaction and automation. This server provides tools to read, search, and manipulate Memories and Conversations. + */ +export interface OmiMcp { + apiKey: string +} +/** + * ONLYOFFICE DocSpace is a room-based collaborative platform which allows organizing a clear file structure depending on users' needs or project goals. + */ +export interface ONLYOFFICEDocSpace { + baseUrl: string + docspaceApiKey: string + docspaceAuthToken: string + docspacePassword: string + docspaceUsername: string + dynamic: boolean + origin: string + toolsets: string + userAgent: string +} +/** + * Fetch, validate, and generate code or curl from any OpenAPI or Swagger spec - all from a single URL. + */ +export interface OpenAPIToolkitForMCP { + mode: string +} +/** + * OpenAPI Schema Model Context Protocol Server. + */ +export interface OpenAPISchema { + SchemaPath: string +} +/** + * MCP Server for searching Airbnb and get listing details. + */ +export interface AirbnbSearch {} +/** + * Discover and connect to a curated marketplace of MCP servers for extending AI agent capabilities. + */ +export interface OpenMesh {} +/** + * A simple MCP service that provides current weather and 5-day forecast using the free OpenWeatherMap API. + */ +export interface Openweather { + owmApiKey: string +} +/** + * Access to OpenZeppelin Cairo Contracts. + */ +export interface OpenZeppelinCairoContracts {} +/** + * Access to OpenZeppelin Solidity Contracts. + */ +export interface OpenZeppelinSolidityContracts {} +/** + * Access to OpenZeppelin Stellar Contracts. + */ +export interface OpenZeppelinStellarContracts {} +/** + * Access to OpenZeppelin Stylus Contracts. + */ +export interface OpenZeppelinStylusContracts {} +/** + * Model Context Protocol (MCP) implementation for Opik enabling seamless IDE integration and unified access to prompts, projects, traces, and metrics. + */ +export interface Opik { + apiBaseUrl: string + apiKey: string + workspaceName: string +} +/** + * A Model Context Protocol (MCP) server for querying deals and evaluations from the Opine CRM API. + */ +export interface OpineMCPServer { + opineApiKey: string +} +/** + * Connect to Oracle databases via MCP, providing secure read-only access with support for schema exploration, query execution, and metadata inspection. + */ +export interface OracleDatabaseMCPServer { + oracleConnectionString: string + oracleUser: string + password: string +} +/** + * A Model Context Protocol (MCP) server that empowers LLMs to use some of Open Srategy Partners' core writing and product marketing techniques. + */ +export interface OSPMarketingTools {} +/** + * A Model Context Protocol (MCP) server that enables AI assistants like Claude to seamlessly access web data through Oxylabs' powerful web scraping technology. + */ +export interface Oxylabs { + password?: string + username: string +} +/** + * A MCP for searching and downloading academic papers from multiple sources like arXiv, PubMed, bioRxiv, etc. + */ +export interface PaperSearch {} +/** + * Connector for Perplexity API, to enable real-time, web-wide research. + */ +export interface Perplexity { + perplexityApiKey: string +} +/** + * An MCP server to help make U.S. Government open datasets AI-friendly. + */ +export interface ProgramIntegrityAlliance { + apiKey: string +} +/** + * Pinecone Assistant MCP server. + */ +export interface PineconeAssistant { + apiKey: string + assistantHost: string +} +/** + * Playwright Model Context Protocol Server - Tool to automate Browsers and APIs in Claude Desktop, Cline, Cursor IDE and More 🔌. + */ +export interface ExecuteAutomationPlaywrightMCP { + data: string +} +/** + * A unified MCP proxy that aggregates multiple MCP servers into one interface, enabling seamless tool discovery and management across all your AI interactions. Manage all your MCP servers from a single connection point with RAG capabilities and real-time notifications. + */ +export interface PluggedInMCPProxy { + /** + * Base URL for the Plugged.in API (optional, defaults to https://plugged.in for cloud or http://localhost:12005 for self-hosted) + */ + pluggedinApiBaseUrl: string + pluggedinApiKey: string +} +/** + * MCP server for Polar Signals Cloud continuous profiling platform, enabling AI assistants to analyze CPU performance, memory usage, and identify optimization opportunities in production systems. + */ +export interface PolarSignals { + polarSignalsApiKey: string +} +/** + * Connect your AI assistant to PomoDash for seamless task and project management. + */ +export interface PomoDash { + apiKey: string +} +/** + * Connect with read-only access to PostgreSQL databases. This server enables LLMs to inspect database schemas and execute read-only queries. + */ +export interface PostgreSQLReadonlyArchived { + url: string +} +/** + * Postman's MCP server connects AI agents, assistants, and chatbots directly to your APIs on Postman. Use natural language to prompt AI to automate work across your Postman collections, environments, workspaces, and more. + */ +export interface PostmanMCPServer { + apiKey: string +} +/** + * Pref Editor is a tool for viewing and editing Android app preferences during development. + */ +export interface PrefEditor {} +/** + * A Model Context Protocol (MCP) server that enables AI assistants to query and analyze Prometheus metrics through standardized interfaces. Connect to your Prometheus instance to retrieve metrics, perform queries, and gain insights into your system's performance and health. + */ +export interface Prometheus { + /** + * The URL of your Prometheus server + */ + prometheusUrl: string +} +/** + * Browser automation and web scraping using Puppeteer. + */ +export interface PuppeteerArchived {} +/** + * Educational Python refactoring assistant that provides guided suggestions for AI assistants. Features: • Step-by-step refactoring instructions without modifying code • Comprehensive code analysis using professional tools (Rope, Radon, Vulture, Jedi, LibCST, Pyrefly) • Educational approach teaching refactoring patterns through guided practice • Support for both guide-only and apply-changes modes • Identifies long functions, high complexity, dead code, and type issues • Provides precise line numbers and specific refactoring instructions • Compatible with all AI assistants (Claude, GPT, Cursor, Continue, etc.) Perfect for developers learning refactoring patterns while maintaining full control over code changes. Acts as a refactoring mentor rather than an automated code modifier. + */ +export interface PythonRefactoringAssistant {} +/** + * The QuantConnect MCP Server is a bridge for AIs (such as Claude and OpenAI o3 Pro) to interact with our cloud platform. When equipped with our MCP, the AI can perform tasks on your behalf through our API such as updating projects, writing strategies, backtesting, and deploying strategies to production live-trading. + */ +export interface QuantConnectMCPServer { + agentname: string + quantconnectapitoken: string + quantconnectuserid: string +} +/** + * A comprehensive security scanner for MCP servers with YARA rules and static analysis capabilities. + */ +export interface RampartsMCPSecurityScanner {} +/** + * Razorpay's Official MCP Server. + */ +export interface Razorpay { + keyId: string + keySecret?: string +} +/** + * A comprehensive Model Context Protocol (MCP) server for Reddit integration. This server enables AI agents to interact with Reddit programmatically through a standardized interface. + */ +export interface McpReddit { + redditClientId: string + redditClientSecret: string + redditPassword: string + username: string +} +/** + * Access to Redis database operations. + */ +export interface Redis { + caCerts: string + caPath: string + certReqs: string + clusterMode: boolean + host: string + port: number + pwd: string + ssl: boolean + sslCertfile: string + sslKeyfile: string + username: string +} +/** + * MCP Server for Redis Cloud's API, allowing you to manage your Redis Cloud resources using natural language. + */ +export interface RedisCloud { + apiKey: string + secretKey?: string +} +/** + * Ref powerful search tool connets your coding tools with documentation context. It includes an up-to-date index of public documentation and it can ingest your private documentation (eg. GitHub repos, PDFs) as well. + */ +export interface RefUpToDateDocs { + apiKey: string +} +/** + * Tools for finding remote MCP servers. + */ +export interface RemoteMCP {} +/** + * Interact with your Render resources via LLMs. + */ +export interface Render { + apiKey: string +} +/** + * Send emails directly from Cursor with this email sending MCP server. + */ +export interface SendEmails { + apiKey?: string + /** + * comma separated list of reply to email addresses + */ + replyTo: string + /** + * sender email address + */ + sender: string +} +/** + * RISKEN's official MCP Server. + */ +export interface RISKEN { + accessToken: string + url: string +} +/** + * MCP server that provides container image vulnerability scanning and remediation capabilities through Root.io. + */ +export interface RootIoVulnerabilityRemediationMCP { + apiAccessToken: string +} +/** + * Python server implementing Model Context Protocol (MCP) for ROS2. + */ +export interface WiseVisionROS2MCPServer {} +/** + * Access to Rube's catalog of remote MCP servers. + */ +export interface Rube { + apiKey: string +} +/** + * The Rust MCP Filesystem is a high-performance, asynchronous, and lightweight Model Context Protocol (MCP) server built in Rust for secure and efficient filesystem operations. Designed with security in mind, it operates in read-only mode by default and restricts clients from updating allowed directories via MCP Roots unless explicitly enabled, ensuring robust protection against unauthorized access. Leveraging asynchronous I/O, it delivers blazingly fast performance with a minimal resource footprint. Optimized for token efficiency, the Rust MCP Filesystem enables large language models (LLMs) to precisely target searches and edits within specific sections of large files and restrict operations by file size range, making it ideal for efficient file exploration, automation, and system integration. + */ +export interface BlazingFastAsynchronousMCPServerForSeamlessFilesystemOperations { + /** + * Enable read/write mode. If false, the app operates in read-only mode. + */ + allowWrite: boolean + /** + * List of directories that rust-mcp-filesystem can access. + */ + allowedDirectories: string[] + /** + * Enable dynamic directory access control via MCP client-side Roots. + */ + enableRoots: boolean +} +/** + * The SchemaCrawler AI MCP Server enables natural language interaction with your database schema using an MCP client in "Agent" mode. It allows users to explore tables, columns, foreign keys, triggers, stored procedures and more simply by asking questions like "Explain the code for the interest calculation stored procedure". You can also ask it to help with SQL, since it knows your schema. This is ideal for developers, DBAs, and data analysts who want to streamline schema comprehension and query development without diving into dense documentation. + */ +export interface SchemaCrawlerAI { + /** + * --info-level How much database metadata to retrieve + */ + generalInfoLevel: string + generalLogLevel?: string + schcrwlrDatabasePassword?: string + schcrwlrDatabaseUser?: string + /** + * --database Database to connect to (optional) + */ + serverConnectionDatabase?: string + /** + * --host Database host (optional) + */ + serverConnectionHost?: string + /** + * --port Database port (optional) + */ + serverConnectionPort?: number + /** + * --server SchemaCrawler database plugin + */ + serverConnectionServer: string + /** + * --url JDBC URL for database connection + */ + urlConnectionJdbcUrl: string + /** + * Host volume to map within the Docker container + */ + volumeHostShare: string +} +/** + * This adds a border to an image and returns base64 encoded image. + */ +export interface SchoginiMCPImageBorder {} +/** + * ScapeGraph MCP Server. + */ +export interface ScrapeGraph { + sgaiApiKey: string +} +/** + * A Model Context Protocol server for Scrapezy that enables AI models to extract structured data from websites. + */ +export interface Scrapezy { + apiKey: string +} +/** + * SecureNote.link MCP Server - allowing AI agents to securely share sensitive information through end-to-end encrypted notes. + */ +export interface SecurenoteLinkMcpServer {} +/** + * MCP server for using Semgrep to scan code for security vulnerabilities. + */ +export interface Semgrep {} +/** + * A Model Context Protocol server for retrieving and analyzing issues from Sentry.io. This server provides tools to inspect error reports, stacktraces, and other debugging information from your Sentry account. + */ +export interface SentryArchived { + authToken: string +} +/** + * Stop stitching context for Copilot and Cursor. With Sequa MCP, your AI tools know your entire codebase and docs out of the box. + */ +export interface SequaAI { + apiKey: string + mcpServerUrl: string +} +/** + * Dynamic and reflective problem-solving through thought sequences. + */ +export interface SequentialThinkingReference {} +/** + * Access to Short.io's link shortener and analytics tools. + */ +export interface ShortIo { + shortIoApiKey: string +} +/** + * Advanced SimpleCheckList with MCP server and SQLite database for comprehensive task management. Features: • Complete project and task management system • Hierarchical organization (Projects → Groups → Task Lists → Tasks → Subtasks) • SQLite database for data persistence • RESTful API with comprehensive endpoints • MCP protocol compliance for AI assistant integration • Docker-optimized deployment with stability improvements **v1.0.1 Update**: Enhanced Docker stability with improved container lifecycle management. Default mode optimized for containerized deployment with reliable startup and shutdown processes. Perfect for AI assistants managing complex project workflows and task hierarchies. + */ +export interface SimpleCheckListMCPServer {} +/** + * MCP server for interacting with SingleStore Management API and services. + */ +export interface Singlestore { + mcpApiKey: string +} +/** + * Interact with Slack Workspaces over the Slack API. + */ +export interface SlackArchived { + botToken?: string + channelIds?: string + teamId: string +} +/** + * MCP server for AI access to SmartBear tools, including BugSnag, Reflect, API Hub, PactFlow. + */ +export interface SmartBearMCPServer { + apiHubApiKey: string + bugsnagApiKey: string + bugsnagAuthToken: string + bugsnagEndpoint: string + pactBrokerBaseUrl: string + pactBrokerPassword: string + pactBrokerToken: string + pactBrokerUsername: string + reflectApiToken: string +} +/** + * Interact with SonarQube Cloud, Server and Community build over the web API. Analyze code to identify quality and security issues. + */ +export interface SonarQube { + /** + * Organization key for SonarQube Cloud, not required for SonarQube Server or Community Build + */ + org: string + token: string + /** + * URL of the SonarQube instance, to provide only for SonarQube Server or Community Build + */ + url: string +} +/** + * Database interaction and business intelligence capabilities. + */ +export interface SQLiteArchived {} +/** + * AI-powered DevOps assistant for managing cloud infrastructure and applications. + */ +export interface StackGen { + token?: string + /** + * URL of your StackGen instance + */ + url: string +} +/** + * A Model Context Protocol (MCP) server for integrating with StackHawk's security scanning platform. Provides security analytics, YAML configuration management, sensitive data/threat surface analysis, and anti-hallucination tools for LLMs. + */ +export interface StackHawk { + apiKey: string +} +/** + * Interact with Stripe services over the Stripe API. + */ +export interface Stripe { + secretKey: string +} +/** + * Official Supadata MCP Server - Adds powerful video & web scraping to Cursor, Claude and any other LLM clients. + */ +export interface Supadata { + apiKey: string +} +/** + * MCP Server to interact with a SuzieQ network observability instance via its REST API. + */ +export interface SuzieqMCP { + apiEndpoint: string + apiKey: string +} +/** + * Model Context Protocol (MCP) server for comprehensive task and feature management, providing AI assistants with a structured, context-efficient way to interact with project data. + */ +export interface TaskOrchestrator {} +/** + * The Tavily MCP server provides seamless interaction with the tavily-search and tavily-extract tools, real-time web search capabilities through the tavily-search tool and Intelligent data extraction from web pages via the tavily-extract tool. + */ +export interface Tavily { + apiKey: string +} +/** + * Tools for Teamwork.com products. + */ +export interface Teamwork { + twMcpBearerToken: string +} +/** + * Enables interaction with powerful telephony, messaging, and AI assistant APIs. + */ +export interface Telnyx { + apiKey: string +} +/** + * MCP server for Tembo Cloud's platform API. + */ +export interface Tembo { + apiKey: string +} +/** + * The Terraform MCP Server provides seamless integration with Terraform ecosystem, enabling advanced automation and interaction capabilities for Infrastructure as Code (IaC) development. + */ +export interface HashicorpTerraform {} +/** + * Transform natural language queries into GraphQL queries using an AI agent. Provides schema management, query validation, execution, and history tracking. + */ +export interface TextToGraphQL { + graphqlApiKey: string + /** + * Authentication method for GraphQL API + */ + graphqlAuthType: string + graphqlEndpoint: string + /** + * OpenAI model to use + */ + modelName: string + /** + * Model temperature for responses + */ + modelTemperature: number + openaiApiKey: string +} +/** + * Tigris is a globally distributed S3-compatible object storage service that provides low latency anywhere in the world, enabling developers to store and access any amount of data for a wide range of use cases. + */ +export interface TigrisData { + awsAccessKeyId: string + awsEndpointUrlS3: string + awsSecretAccessKey?: string +} +/** + * Time and timezone conversion capabilities. + */ +export interface TimeReference {} +/** + * Triplewhale MCP Server. + */ +export interface Triplewhale { + apiKey: string +} +/** + * A comprehensive Model Context Protocol (MCP) server that enables AI assistants to control Unreal Engine via Remote Control API. Built with TypeScript and designed for game development automation. + */ +export interface UnrealEngineMCPServer { + /** + * Logging level + */ + logLevel?: string + /** + * Unreal Engine host address. Use: host.docker.internal for local UE on Windows/Mac Docker, 127.0.0.1 for Linux without Docker, or actual IP address (e.g., 192.168.1.100) for remote UE + */ + ueHost: string + /** + * Remote Control HTTP port + */ + ueRcHttpPort: string + /** + * Remote Control WebSocket port + */ + ueRcWsPort: string +} +/** + * VeyraX MCP is the only connection you need to access all your tools in any MCP-compatible environment. + */ +export interface VeyraX { + apiKey: string +} +/** + * provides tools and templates to create a functioning Vizro chart or dashboard step by step. + */ +export interface Vizro {} +/** + * This MCP server exposes tools to query the NVD/CVE REST API and return formatted text results suitable for LLM consumption via the MCP protocol. It includes automatic query chunking for large date ranges and parallel processing for improved performance. + */ +export interface VulnNistMcpServer {} +/** + * Wayfound’s MCP server allows business users to govern, supervise, and improve AI Agents. + */ +export interface WayfoundMCP { + mcpApiKey: string +} +/** + * Model Context Protocol (MCP) server for the Webflow Data API. + */ +export interface Webflow { + token: string +} +/** + * A Model Context Protocol (MCP) server that retrieves information from Wikipedia to provide context to LLMs. + */ +export interface Wikipedia {} +/** + * Connect your chat repl to wolfram alpha computational intelligence. + */ +export interface WolframAlpha { + wolframApiKey: string +} +/** + * Retrieves transcripts for given YouTube video URLs. + */ +export interface YouTubeTranscripts {} +/** + * MCP server for Zerodha Kite Connect API - India's leading stock broker trading platform. Execute trades, manage portfolios, and access real-time market data for NSE, BSE, and other Indian exchanges. + */ +export interface ZerodhaKiteConnect { + /** + * Access token obtained after OAuth authentication (optional - can be generated at runtime) + */ + kiteAccessToken?: string + /** + * Your Kite Connect API key from the developer console + */ + kiteApiKey: string + kiteApiSecret?: string + /** + * OAuth redirect URL configured in your Kite Connect app + */ + kiteRedirectUrl?: string +} diff --git a/packages/js-sdk/src/sandbox/network.ts b/packages/js-sdk/src/sandbox/network.ts new file mode 100644 index 0000000..39314f8 --- /dev/null +++ b/packages/js-sdk/src/sandbox/network.ts @@ -0,0 +1,4 @@ +/** + * CIDR range that represents all traffic. + */ +export const ALL_TRAFFIC = '0.0.0.0/0' diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts new file mode 100644 index 0000000..4cedebc --- /dev/null +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -0,0 +1,1378 @@ +import { ApiClient, components, handleApiError } from '../api' +import { + ConnectionConfig, + ConnectionOpts, + DEFAULT_SANDBOX_TIMEOUT_MS, +} from '../connectionConfig' +import { compareVersions } from 'compare-versions' +import { ALL_TRAFFIC } from './network' +import { + InvalidArgumentError, + SandboxNotFoundError, + TemplateError, +} from '../errors' +import { Paginator } from '../paginator' +import { timeoutToSeconds } from '../utils' +import type { Volume } from '../volume' +import type { McpServer as BaseMcpServer } from './mcp' + +/** + * Extended MCP server configuration that includes base servers + * and allows dynamic GitHub-based MCP servers with custom run and install commands. + */ +export type McpServer = BaseMcpServer | GitHubMcpServer + +export type GitHubMcpServer = { + [key: `github/${string}`]: { + /** + * Command to run the MCP server. Must start a stdio-compatible server. + */ + runCmd: string + /** + * Command to install dependencies for the MCP server. Working directory is the root of the github repository. + */ + installCmd?: string + /** + * Environment variables to set in the MCP process. + */ + envs?: Record + } +} + +/** + * Transform applied to egress requests matching a {@link SandboxNetworkRule}. + */ +export type SandboxNetworkTransform = { + /** + * Headers to inject into the outbound request. Values override any headers + * already present on the request. + */ + headers?: Record +} + +/** + * Per-domain rule applied to egress requests. + */ +export type SandboxNetworkRule = { + /** + * Transform applied to requests matching this rule. + */ + transform?: SandboxNetworkTransform +} + +/** + * Map of host (or CIDR / IP) to ordered list of rules applied to outbound + * requests for that host. Accepts either a plain object or a `Map`. + * Registering a host here does not allow egress on its own — the host must + * also appear in {@link SandboxNetworkOpts.allowOut}. + */ +export type SandboxNetworkRules = + | Record + | Map + +/** + * Per-domain rule as returned by the sandbox info endpoint. Mirrors + * {@link SandboxNetworkRule} but with `transform` always materialized to the + * static {@link SandboxNetworkTransform} shape — no callback variant. + */ +export type SandboxNetworkRuleInfo = { + transform?: SandboxNetworkTransform +} + +/** + * Context passed to {@link SandboxNetworkOpts.allowOut} and + * {@link SandboxNetworkOpts.denyOut} when they are defined as functions. + */ +export type SandboxNetworkSelectorContext = { + /** All traffic sentinel — equivalent to `'0.0.0.0/0'`. */ + allTraffic: string + /** Rules registered in {@link SandboxNetworkOpts.rules}. */ + rules: Map +} + +/** + * Egress rule list, either a static array of CIDR blocks / IP addresses / + * hostnames, or a callback that receives `{ allTraffic, rules }` and returns + * the same. + */ +export type SandboxNetworkSelector = + | string[] + | ((ctx: SandboxNetworkSelectorContext) => string[]) + +export type SandboxNetworkOpts = { + /** + * Allow outbound traffic from the sandbox to the specified addresses. + * If `allowOut` is not specified, all outbound traffic is allowed. + * + * Accepts either a static array of CIDR blocks, IP addresses, or hostnames, + * or a callback that receives `{ allTraffic, rules }` and returns the same. + * `allTraffic` is `'0.0.0.0/0'`; `rules` is a `Map` view of + * {@link SandboxNetworkOpts.rules}. + * + * Examples: + * - Static list: `["1.1.1.1", "8.8.8.0/24"]` + * - Allow only rule-registered hosts: + * `({ rules }) => [...rules.keys()]` + */ + allowOut?: SandboxNetworkSelector + + /** + * Deny outbound traffic from the sandbox to the specified addresses. + * + * Accepts the same shapes as {@link allowOut}. + * + * Examples: + * - Static list: `["1.1.1.1", "8.8.8.0/24"]` + * - Block all egress: `({ allTraffic }) => [allTraffic]` + */ + denyOut?: SandboxNetworkSelector + + /** + * Per-domain transform rules applied to matching egress HTTP/HTTPS + * requests. Keys are domains (e.g. `"api.example.com"`); values are + * ordered lists of rules. + * + * Registering a host here does not allow egress on its own — the host must + * also appear in {@link allowOut}. Hosts registered here are exposed to the + * `allowOut`/`denyOut` callbacks via `rules`. + * + * @example + * ```ts + * await Sandbox.create({ + * network: { + * allowOut: ({ rules }) => [...rules.keys()], + * rules: { + * 'api.openai.com': [ + * { transform: { headers: { Authorization: `Bearer ${token}` } } }, + * ], + * }, + * }, + * }) + * ``` + */ + rules?: SandboxNetworkRules + + /** + * Specify if the sandbox URLs should be accessible only with authentication. + * @default true + */ + allowPublicTraffic?: boolean + + /** Specify host mask which will be used for all sandbox requests in the header. + * You can use the ${PORT} variable that will be replaced with the actual port number of the service. + * + * @default ${PORT}-sandboxid.e2b.app + */ + maskRequestHost?: string +} + +/** + * Network configuration as returned by the sandbox info endpoint. Mirrors + * {@link SandboxNetworkOpts} but with `allowOut`/`denyOut` always materialized + * to plain string arrays. + */ +export type SandboxNetworkInfo = { + allowOut?: string[] + denyOut?: string[] + rules?: Record + allowPublicTraffic?: boolean + maskRequestHost?: string +} + +/** + * Subset of {@link SandboxNetworkOpts} accepted by {@link SandboxApi.updateNetwork}. + * The update endpoint replaces all egress rules atomically — fields that are + * omitted are cleared on the server. + */ +export type SandboxNetworkUpdate = { + /** See {@link SandboxNetworkOpts.allowOut}. */ + allowOut?: SandboxNetworkSelector + /** See {@link SandboxNetworkOpts.denyOut}. */ + denyOut?: SandboxNetworkSelector + /** See {@link SandboxNetworkOpts.rules}. */ + rules?: SandboxNetworkRules + /** + * Allow sandbox to access the internet. When set to `false`, it behaves the + * same as specifying `denyOut: ['0.0.0.0/0']` in the network config. + */ + allowInternetAccess?: boolean +} + +/** + * What happens when the sandbox timeout is reached. Either the bare action + * (`'pause'` / `'kill'`), or an object form that also controls the pause + * snapshot kind via `keepMemory`. + * + * The object form is a discriminated union on `action`: `keepMemory` is only + * accepted alongside `action: 'pause'`. Passing `keepMemory` with + * `action: 'kill'` is a compile-time type error. + */ +export type SandboxOnTimeout = + | 'pause' + | 'kill' + | { + /** Auto-pause the sandbox when the timeout is reached. */ + action: 'pause' + + /** + * Whether the timeout auto-pause keeps a full memory snapshot. + * + * When `false`, the auto-pause drops the in-memory state and persists only + * the filesystem (a filesystem-only snapshot); resuming such a sandbox + * cold-boots (reboots) it from disk, losing running processes and open + * connections. + * + * Cannot be combined with `autoResume`: auto-resume wakes a paused sandbox + * on inbound traffic by restoring its memory snapshot in place, so the + * request that woke it hits an already-running process. A filesystem-only + * snapshot has no memory to restore — resuming cold-boots it — so it can't + * be woken transparently by traffic and must be resumed explicitly via + * `connect()`. + * + * @default true + */ + keepMemory?: boolean + } + | { + /** Kill the sandbox when the timeout is reached. */ + action: 'kill' + } + +export type SandboxLifecycle = { + /** + * Action to take when sandbox timeout is reached. Accepts either `'pause'` / + * `'kill'`, or `{ action, keepMemory }` to also control the pause snapshot kind. + * @default "kill" + */ + onTimeout: SandboxOnTimeout + + /** + * Auto-resume enabled flag. + * @default false + * Can be `true` only when `onTimeout` is `pause`. Not supported when + * `keepMemory` is `false` (a filesystem-only snapshot must be resumed + * explicitly via `connect()`). + */ + autoResume?: boolean +} + +export type SandboxInfoLifecycle = { + /** + * Action to take when sandbox timeout is reached. + */ + onTimeout: 'pause' | 'kill' + + /** + * Whether the sandbox can auto-resume. + */ + autoResume: boolean +} + +/** + * Options for request to the Sandbox API. + */ +export interface SandboxApiOpts + extends Partial< + Pick< + ConnectionOpts, + | 'apiKey' + | 'validateApiKey' + | 'headers' + | 'apiHeaders' + | 'debug' + | 'domain' + | 'requestTimeoutMs' + | 'signal' + > + > {} + +/** + * Options for pausing a sandbox. + */ +export interface SandboxPauseOpts extends SandboxApiOpts { + /** + * Whether to keep a full memory snapshot. + * + * When `false`, the in-memory state is dropped and only the filesystem is + * persisted (a filesystem-only snapshot); resuming such a sandbox cold-boots + * (reboots) it from disk, losing running processes and open connections. + * + * @default true + */ + keepMemory?: boolean +} + +/** + * Options for creating a new Sandbox. + */ +export interface SandboxOpts extends ConnectionOpts { + /** + * Sandbox template name or ID. + * + * @default 'base' (or 'mcp-gateway' when `mcp` option is set) + */ + template?: string + + /** + * Custom metadata for the sandbox. + * + * @default {} + */ + metadata?: Record + + /** + * Custom environment variables for the sandbox. + * + * Used when executing commands and code in the sandbox. + * Can be overridden with the `envs` argument when executing commands or code. + * + * @default {} + */ + envs?: Record + + /** + * Timeout for the sandbox in **milliseconds**. + * Maximum time a sandbox can be kept alive is 24 hours (86_400_000 milliseconds) for Pro users and 1 hour (3_600_000 milliseconds) for Hobby users. + * + * @default 300_000 // 5 minutes + */ + timeoutMs?: number + + /** + * Secure all traffic coming to the sandbox controller with auth token + * + * @default true + */ + secure?: boolean + + /** + * Allow sandbox to access the internet. If set to `False`, it works the same as setting network `denyOut` to `[0.0.0.0/0]`. + * + * @default true + */ + allowInternetAccess?: boolean + + /** + * MCP server to enable in the sandbox + * @default undefined + */ + mcp?: McpServer + + /** + * Sandbox network configuration + */ + network?: SandboxNetworkOpts + + /** + * Volume mounts for the sandbox. + * + * The keys are mount paths inside the sandbox and the values are either + * a `Volume` instance or a string representing the volume name. + * + * @default undefined + */ + volumeMounts?: Record + + /** + * Sandbox URL. Used for local development + */ + sandboxUrl?: string + + /** + * Sandbox lifecycle configuration. + */ + lifecycle?: SandboxLifecycle +} + +/** + * Options for connecting to a Sandbox. + */ +export type SandboxConnectOpts = ConnectionOpts & { + /** + * Timeout for the sandbox in **milliseconds**. + * For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + * Maximum time a sandbox can be kept alive is 24 hours (86_400_000 milliseconds) for Pro users and 1 hour (3_600_000 milliseconds) for Hobby users. + * + * @default 300_000 // 5 minutes + */ + timeoutMs?: number +} + +/** + * State of the sandbox. + */ +export type SandboxState = 'running' | 'paused' + +export interface SandboxListOpts extends Omit { + /** + * Filter the list of sandboxes, e.g. by metadata `metadata:{"key": "value"}`, if there are multiple filters they are combined with AND. + * + */ + query?: { + metadata?: Record + /** + * Filter the list of sandboxes by state. + * @default ['running', 'paused'] + */ + state?: Array + } + + /** + * Number of sandboxes to return per page. + * + * @default 100 + */ + limit?: number + + /** + * Token to the next page. + */ + nextToken?: string +} + +export interface SandboxMetricsOpts extends SandboxApiOpts { + /** + * Start time for the metrics, defaults to the start of the sandbox + */ + start?: Date + /** + * End time for the metrics, defaults to the current time + */ + end?: Date +} + +/** + * Options for listing snapshots. + */ +export interface SnapshotListOpts extends Omit { + /** + * Filter snapshots by source sandbox ID. + */ + sandboxId?: string + + /** + * Number of snapshots to return per page. + * + * @default 100 + */ + limit?: number + + /** + * Token to the next page. + */ + nextToken?: string +} + +/** + * Information about a snapshot. + */ +export interface SnapshotInfo { + /** + * Snapshot identifier — template ID with tag, or namespaced name with tag (e.g. my-snapshot:latest). + * Can be used with Sandbox.create() to create a new sandbox from this snapshot. + */ + snapshotId: string + + /** + * Full names of the snapshot template including team namespace and tag (e.g. team-slug/my-snapshot:v2). + */ + names: string[] +} + +/** + * Options for creating a snapshot. + */ +export interface CreateSnapshotOpts extends SandboxApiOpts { + /** + * Optional name for the snapshot template. + * If a snapshot template with this name already exists, a new build will be assigned + * to the existing template instead of creating a new one. + */ + name?: string +} + +/** + * Information about a sandbox. + */ +export interface SandboxInfo { + /** + * Sandbox ID. + */ + sandboxId: string + + /** + * Template ID. + */ + templateId: string + + /** + * Template name. + */ + name?: string + + /** + * Saved sandbox metadata. + */ + metadata: Record + + /** + * Sandbox start time. + */ + startedAt: Date + + /** + * Sandbox expiration date. + */ + endAt: Date + + /** + * Sandbox state. + * + * @string can be `running` or `paused` + */ + state: SandboxState + + /** + * Sandbox CPU count. + */ + cpuCount: number + + /** + * Sandbox Memory size in MiB. + */ + memoryMB: number + + /** + * Envd version. + */ + envdVersion: string + + /** + * Whether internet access was explicitly enabled or disabled for the sandbox. + */ + allowInternetAccess?: boolean | undefined + + /** + * Sandbox network configuration. + */ + network?: SandboxNetworkInfo + + /** + * Sandbox lifecycle configuration. + */ + lifecycle?: SandboxInfoLifecycle + + /** + * Volume mounts for the sandbox. + */ + volumeMounts?: Array<{ name: string; path: string }> + + /** + * Sandbox domain. + */ + sandboxDomain?: string +} + +/** + * Sandbox resource usage metrics. + */ +export interface SandboxMetrics { + /** + * Timestamp of the metrics. + */ + timestamp: Date + + /** + * CPU usage in percentage. + */ + cpuUsedPct: number + + /** + * Number of CPU cores. + */ + cpuCount: number + + /** + * Memory usage in bytes. + */ + memUsed: number + + /** + * Total memory available in bytes. + */ + memTotal: number + + /** + * Cached memory (page cache) in bytes. + */ + memCache: number + + /** + * Used disk space in bytes. + */ + diskUsed: number + + /** + * Total disk space available in bytes. + */ + diskTotal: number +} + +function resolveNetworkSelector( + selector: SandboxNetworkSelector | undefined, + rules: Map +): string[] | undefined { + if (selector === undefined) { + return undefined + } + + if (typeof selector === 'function') { + return selector({ allTraffic: ALL_TRAFFIC, rules }) + } + + return selector +} + +function resolveRulesForBody( + rules: Map +): Record { + const out: Record = {} + for (const [host, hostRules] of rules) { + out[host] = hostRules.map((rule) => + rule.transform === undefined ? {} : { transform: rule.transform } + ) + } + return out +} + +type NetworkEgressBody = { + allowOut?: string[] + denyOut?: string[] + rules?: Record +} + +function buildNetworkEgress(network: { + allowOut?: SandboxNetworkSelector + denyOut?: SandboxNetworkSelector + rules?: SandboxNetworkRules +}): NetworkEgressBody { + const rules = + network.rules instanceof Map + ? network.rules + : new Map(Object.entries(network.rules ?? {})) + const allowOut = resolveNetworkSelector(network.allowOut, rules) + const denyOut = resolveNetworkSelector(network.denyOut, rules) + + return { + ...(allowOut !== undefined ? { allowOut } : {}), + ...(denyOut !== undefined ? { denyOut } : {}), + ...(network.rules !== undefined + ? { rules: resolveRulesForBody(rules) } + : {}), + } +} + +function buildNetworkBody( + network: SandboxNetworkOpts | undefined +): components['schemas']['SandboxNetworkConfig'] | undefined { + if (!network) { + return undefined + } + + return { + ...buildNetworkEgress(network), + ...(network.allowPublicTraffic !== undefined + ? { allowPublicTraffic: network.allowPublicTraffic } + : {}), + ...(network.maskRequestHost !== undefined + ? { maskRequestHost: network.maskRequestHost } + : {}), + } +} + +function buildNetworkUpdateBody( + network: SandboxNetworkUpdate +): components['schemas']['SandboxNetworkUpdateConfig'] { + return { + ...buildNetworkEgress(network), + ...(network.allowInternetAccess !== undefined + ? { allow_internet_access: network.allowInternetAccess } + : {}), + } +} +export class SandboxApi { + protected constructor() {} + + /** + * Kill the sandbox specified by sandbox ID. + * + * @param sandboxId sandbox ID. + * @param opts connection options. + * + * @returns `true` if the sandbox was found and killed, `false` otherwise. + */ + static async kill( + sandboxId: string, + opts?: SandboxApiOpts + ): Promise { + const config = new ConnectionConfig(opts) + + if (config.debug) { + // Skip killing the sandbox in debug mode + return true + } + + const client = new ApiClient(config) + + const res = await client.api.DELETE('/sandboxes/{sandboxID}', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + if (res.error?.code === 404) { + return false + } + + const err = handleApiError(res) + if (err) { + throw err + } + + return true + } + + /** + * Get sandbox information like sandbox ID, template, metadata, started at/end at date. + * + * @param sandboxId sandbox ID. + * @param opts connection options. + * + * @returns sandbox information. + */ + static async getInfo( + sandboxId: string, + opts?: SandboxApiOpts + ): Promise { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.GET('/sandboxes/{sandboxID}', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + if (res.error?.code === 404) { + throw new SandboxNotFoundError(`Sandbox ${sandboxId} not found`) + } + + const err = handleApiError(res) + if (err) { + throw err + } + + if (!res.data) { + throw new Error('Sandbox not found') + } + + return { + sandboxId: res.data.sandboxID, + templateId: res.data.templateID, + ...(res.data.alias && { name: res.data.alias }), + metadata: res.data.metadata ?? {}, + allowInternetAccess: res.data.allowInternetAccess ?? undefined, + envdVersion: res.data.envdVersion, + startedAt: new Date(res.data.startedAt), + endAt: new Date(res.data.endAt), + state: res.data.state, + cpuCount: res.data.cpuCount, + memoryMB: res.data.memoryMB, + network: res.data.network + ? { + allowOut: res.data.network.allowOut, + denyOut: res.data.network.denyOut, + rules: res.data.network.rules ?? undefined, + allowPublicTraffic: res.data.network.allowPublicTraffic, + maskRequestHost: res.data.network.maskRequestHost, + } + : undefined, + lifecycle: res.data.lifecycle + ? { + onTimeout: res.data.lifecycle.onTimeout, + autoResume: res.data.lifecycle.autoResume, + } + : undefined, + sandboxDomain: res.data.domain || undefined, + volumeMounts: res.data.volumeMounts ?? [], + } + } + + /** + * @deprecated Use {@link Sandbox.getInfo} instead. + * + * @param sandboxId sandbox ID. + * @param opts connection options. + * + * @returns sandbox information. + */ + static async getFullInfo( + sandboxId: string, + opts?: SandboxApiOpts + ): Promise { + return await this.getInfo(sandboxId, opts) + } + + /** + * Get the metrics of the sandbox. + * + * @param sandboxId sandbox ID. + * @param opts sandbox metrics options. + * + * @returns List of sandbox metrics containing CPU, memory and disk usage information. + */ + static async getMetrics( + sandboxId: string, + opts?: SandboxMetricsOpts + ): Promise { + const config = new ConnectionConfig(opts) + + if (config.debug) { + // Skip getting the metrics in debug mode + return [] + } + + const client = new ApiClient(config) + + // JS timestamp is in milliseconds, convert to unix (seconds) + const start = opts?.start + ? Math.round(opts.start.getTime() / 1000) + : undefined + const end = opts?.end ? Math.round(opts.end.getTime() / 1000) : undefined + const res = await client.api.GET('/sandboxes/{sandboxID}/metrics', { + params: { + path: { + sandboxID: sandboxId, + }, + query: { + start, + end, + }, + }, + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + if (res.error?.code === 404) { + throw new SandboxNotFoundError(`Sandbox ${sandboxId} not found`) + } + + const err = handleApiError(res) + if (err) { + throw err + } + + return ( + res.data?.map((metric: components['schemas']['SandboxMetric']) => ({ + timestamp: new Date(metric.timestamp), + cpuUsedPct: metric.cpuUsedPct, + cpuCount: metric.cpuCount, + memUsed: metric.memUsed, + memTotal: metric.memTotal, + memCache: metric.memCache, + diskUsed: metric.diskUsed, + diskTotal: metric.diskTotal, + })) ?? [] + ) + } + + /** + * Set the timeout of the specified sandbox. + * After the timeout expires the sandbox will be automatically killed. + * + * This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to {@link Sandbox.setTimeout}. + * + * Maximum time a sandbox can be kept alive is 24 hours (86_400_000 milliseconds) for Pro users and 1 hour (3_600_000 milliseconds) for Hobby users. + * + * @param sandboxId sandbox ID. + * @param timeoutMs timeout in **milliseconds**. + * @param opts connection options. + */ + static async setTimeout( + sandboxId: string, + timeoutMs: number, + opts?: SandboxApiOpts + ): Promise { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.POST('/sandboxes/{sandboxID}/timeout', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + body: { + timeout: timeoutToSeconds(timeoutMs), + }, + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + if (res.error?.code === 404) { + throw new SandboxNotFoundError(`Sandbox ${sandboxId} not found`) + } + + const err = handleApiError(res) + if (err) { + throw err + } + } + + /** + * Update the network configuration of a running sandbox. + * + * Replaces the current egress configuration atomically — fields that are + * omitted are cleared on the server. + * + * @param sandboxId sandbox ID. + * @param network new network configuration. + * @param opts connection options. + */ + static async updateNetwork( + sandboxId: string, + network: SandboxNetworkUpdate, + opts?: SandboxApiOpts + ): Promise { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.PUT('/sandboxes/{sandboxID}/network', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + body: buildNetworkUpdateBody(network), + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + if (res.error?.code === 404) { + throw new SandboxNotFoundError(`Sandbox ${sandboxId} not found`) + } + + const err = handleApiError(res) + if (err) { + throw err + } + } + + /** + * Pause the sandbox specified by sandbox ID. + * + * @param sandboxId sandbox ID. + * @param opts pause options, including `keepMemory` and connection options. + * + * @returns `true` if the sandbox got paused, `false` if the sandbox was already paused. + */ + static async pause( + sandboxId: string, + opts?: SandboxPauseOpts + ): Promise { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.POST('/sandboxes/{sandboxID}/pause', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + body: { + memory: opts?.keepMemory ?? true, + }, + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + if (res.error?.code === 404) { + throw new SandboxNotFoundError(`Sandbox ${sandboxId} not found`) + } + + if (res.error?.code === 409) { + // Sandbox is already paused + return false + } + + const err = handleApiError(res) + if (err) { + throw err + } + + return true + } + + /** + * @deprecated Use {@link SandboxApi.pause} instead. + */ + static async betaPause( + sandboxId: string, + opts?: SandboxPauseOpts + ): Promise { + return this.pause(sandboxId, opts) + } + + /** + * Create a snapshot from a sandbox. + * + * The sandbox will be paused while the snapshot is being created. + * The snapshot can be used to create new sandboxes with the same state. + * The snapshot is a persistent image that survives sandbox deletion. + * + * @param sandboxId sandbox ID to create snapshot from. + * @param opts snapshot creation options including optional name and connection options. + * + * @returns snapshot information including the snapshot name that can be used with Sandbox.create(). + */ + static async createSnapshot( + sandboxId: string, + opts?: CreateSnapshotOpts + ): Promise { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.POST('/sandboxes/{sandboxID}/snapshots', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + body: opts?.name ? { name: opts.name } : {}, + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + if (res.error?.code === 404) { + throw new SandboxNotFoundError(`Sandbox ${sandboxId} not found`) + } + + const err = handleApiError(res) + if (err) { + throw err + } + + return { + snapshotId: res.data!.snapshotID, + names: res.data!.names ?? [], + } + } + + /** + * List all snapshots. + * + * @param opts list options including filters and pagination. + * + * @returns paginator for listing snapshots. + */ + static listSnapshots(opts?: SnapshotListOpts): SnapshotPaginator { + return new SnapshotPaginator(opts) + } + + /** + * Delete a snapshot. + * + * @param snapshotId snapshot ID. + * @param opts connection options. + * + * @returns `true` if the snapshot was deleted, `false` if it was not found. + */ + static async deleteSnapshot( + snapshotId: string, + opts?: SandboxApiOpts + ): Promise { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.DELETE('/templates/{templateID}', { + params: { + path: { + templateID: snapshotId, + }, + }, + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + if (res.error?.code === 404) { + return false + } + + const err = handleApiError(res) + if (err) { + throw err + } + + return true + } + + protected static async createSandbox( + template: string, + timeoutMs: number, + opts?: SandboxOpts + ) { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + // onTimeout accepts a bare action (`'pause'` / `'kill'`) or the object form + // `{ action, keepMemory }`. The discriminated union type forbids `keepMemory` + // on `action: 'kill'`; re-check at runtime for untyped (JS) callers. + const onTimeout = opts?.lifecycle?.onTimeout ?? 'kill' + const action = typeof onTimeout === 'string' ? onTimeout : onTimeout.action + const hasKeepMemory = + typeof onTimeout !== 'string' && 'keepMemory' in onTimeout + const keepMemory = + typeof onTimeout !== 'string' && 'keepMemory' in onTimeout + ? (onTimeout.keepMemory ?? true) + : true + const autoResume = opts?.lifecycle?.autoResume ?? false + + if (hasKeepMemory && action !== 'pause') { + throw new InvalidArgumentError( + "onTimeout.keepMemory is only allowed when action is 'pause'." + ) + } + + if (autoResume && action !== 'pause') { + throw new InvalidArgumentError( + "autoResume can only be true when onTimeout action is 'pause'." + ) + } + + if (!keepMemory && autoResume) { + throw new InvalidArgumentError( + 'autoResume: true is not a valid value when keepMemory: false - a filesystem-only snapshot cannot be auto-resumed by traffic and must be resumed explicitly using Sandbox.connect().' + ) + } + + const body: components['schemas']['NewSandbox'] = { + templateID: template, + metadata: opts?.metadata, + mcp: opts?.mcp as Record | undefined, + envVars: opts?.envs, + timeout: timeoutToSeconds(timeoutMs), + secure: opts?.secure ?? true, + allow_internet_access: opts?.allowInternetAccess ?? true, + network: buildNetworkBody(opts?.network), + autoPause: action === 'pause', + autoPauseMemory: action === 'pause' ? keepMemory : undefined, + autoResume: { enabled: autoResume }, + } + + if (opts?.volumeMounts) { + body.volumeMounts = Object.entries(opts.volumeMounts).map( + ([mountPath, vol]) => ({ + name: typeof vol === 'string' ? vol : vol.name, + path: mountPath, + }) + ) + } + + const res = await client.api.POST('/sandboxes', { + body, + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + const err = handleApiError(res) + if (err) { + throw err + } + + if (compareVersions(res.data!.envdVersion, '0.1.0') < 0) { + await this.kill(res.data!.sandboxID, opts) + throw new TemplateError( + 'You need to update the template to use the new SDK.' + ) + } + + return { + sandboxId: res.data!.sandboxID, + sandboxDomain: res.data!.domain || undefined, + envdVersion: res.data!.envdVersion, + envdAccessToken: res.data!.envdAccessToken, + trafficAccessToken: res.data!.trafficAccessToken || undefined, + } + } + + protected static async connectSandbox( + sandboxId: string, + opts?: SandboxConnectOpts + ) { + const timeoutMs = opts?.timeoutMs ?? DEFAULT_SANDBOX_TIMEOUT_MS + + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.POST('/sandboxes/{sandboxID}/connect', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + body: { + timeout: timeoutToSeconds(timeoutMs), + }, + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + if (res.error?.code === 404) { + throw new SandboxNotFoundError(`Paused sandbox ${sandboxId} not found`) + } + + const err = handleApiError(res) + if (err) { + throw err + } + + return { + sandboxId: res.data!.sandboxID, + sandboxDomain: res.data!.domain || undefined, + envdVersion: res.data!.envdVersion, + envdAccessToken: res.data!.envdAccessToken, + trafficAccessToken: res.data!.trafficAccessToken || undefined, + } + } +} + +/** + * Paginator for listing sandboxes. + * + * @example + * ```ts + * const paginator = Sandbox.list() + * while (paginator.hasNext) { + * const sandboxes = await paginator.nextItems() + * console.log(sandboxes) + * } + * ``` + */ +export class SandboxPaginator extends Paginator { + private query: SandboxListOpts['query'] + + constructor(opts?: SandboxListOpts) { + super(opts, opts?.limit, opts?.nextToken) + + this.query = opts?.query + } + + async nextItems(opts?: SandboxApiOpts): Promise { + if (!this.hasNext) { + throw new Error('No more items to fetch') + } + + let metadata = undefined + if (this.query?.metadata) { + const encodedPairs: Record = Object.fromEntries( + Object.entries(this.query.metadata).map(([key, value]) => [ + encodeURIComponent(key), + encodeURIComponent(value), + ]) + ) + + metadata = new URLSearchParams(encodedPairs).toString() + } + + const config = new ConnectionConfig({ ...this.opts, ...opts }) + const client = new ApiClient(config) + + const res = await client.api.GET('/v2/sandboxes', { + params: { + query: { + metadata, + state: this.query?.state, + limit: this.limit, + nextToken: this.nextToken, + }, + }, + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + const err = handleApiError(res) + if (err) { + throw err + } + + this.updatePagination(res.response) + + return (res.data ?? []).map( + (sandbox: components['schemas']['ListedSandbox']) => ({ + sandboxId: sandbox.sandboxID, + templateId: sandbox.templateID, + ...(sandbox.alias && { name: sandbox.alias }), + metadata: sandbox.metadata ?? {}, + startedAt: new Date(sandbox.startedAt), + endAt: new Date(sandbox.endAt), + state: sandbox.state, + cpuCount: sandbox.cpuCount, + memoryMB: sandbox.memoryMB, + envdVersion: sandbox.envdVersion, + volumeMounts: sandbox.volumeMounts ?? [], + }) + ) + } +} + +/** + * Paginator for listing snapshots. + * + * @example + * ```ts + * const paginator = Sandbox.listSnapshots() + * while (paginator.hasNext) { + * const snapshots = await paginator.nextItems() + * console.log(snapshots) + * } + * ``` + */ +export class SnapshotPaginator extends Paginator { + private readonly sandboxId?: string + + constructor(opts?: SnapshotListOpts) { + super(opts, opts?.limit, opts?.nextToken) + + this.sandboxId = opts?.sandboxId + } + + async nextItems(opts?: SandboxApiOpts): Promise { + if (!this.hasNext) { + throw new Error('No more items to fetch') + } + + const config = new ConnectionConfig({ ...this.opts, ...opts }) + const client = new ApiClient(config) + + const res = await client.api.GET('/snapshots', { + params: { + query: { + sandboxID: this.sandboxId, + limit: this.limit, + nextToken: this.nextToken, + }, + }, + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + const err = handleApiError(res) + if (err) { + throw err + } + + this.updatePagination(res.response) + + return (res.data ?? []).map( + (snapshot: components['schemas']['SnapshotInfo']) => ({ + snapshotId: snapshot.snapshotID, + names: snapshot.names ?? [], + }) + ) + } +} diff --git a/packages/js-sdk/src/sandbox/signature.ts b/packages/js-sdk/src/sandbox/signature.ts new file mode 100644 index 0000000..ed05fa9 --- /dev/null +++ b/packages/js-sdk/src/sandbox/signature.ts @@ -0,0 +1,61 @@ +import { sha256 } from '../utils' + +/** + * Get the URL signature for the specified path, operation and user. + * + * @param path Path to the file in the sandbox. + * + * @param operation File system operation. Can be either `read` or `write`. + * + * @param user Sandbox user. + * + * @param expirationInSeconds Optional signature expiration time in seconds. + */ + +interface SignatureOpts { + path: string + operation: 'read' | 'write' + user: string | undefined + expirationInSeconds?: number + envdAccessToken?: string +} + +export async function getSignature({ + path, + operation, + user, + expirationInSeconds, + envdAccessToken, +}: SignatureOpts): Promise<{ signature: string; expiration: number | null }> { + if (!envdAccessToken) { + throw new Error( + 'Access token is not set and signature cannot be generated!' + ) + } + + // expiration is unix timestamp + const signatureExpiration = + expirationInSeconds != null + ? Math.floor(Date.now() / 1000) + expirationInSeconds + : null + let signatureRaw: string + + // if user is undefined, set it to empty string to handle default user + if (user == undefined) { + user = '' + } + + if (signatureExpiration === null) { + signatureRaw = `${path}:${operation}:${user}:${envdAccessToken}` + } else { + signatureRaw = `${path}:${operation}:${user}:${envdAccessToken}:${signatureExpiration.toString()}` + } + + const hashBase64 = await sha256(signatureRaw) + const signature = 'v1_' + hashBase64.replace(/=+$/, '') + + return { + signature: signature, + expiration: signatureExpiration, + } +} diff --git a/packages/js-sdk/src/template/buildApi.ts b/packages/js-sdk/src/template/buildApi.ts new file mode 100644 index 0000000..8b48b62 --- /dev/null +++ b/packages/js-sdk/src/template/buildApi.ts @@ -0,0 +1,462 @@ +import type { Readable } from 'node:stream' +import { ApiClient, handleApiError, components } from '../api' +import { buildRequestSignal } from '../connectionConfig' +import { dynamicImport } from '../utils' +import { BuildError, FileUploadError, TemplateError } from '../errors' +import { FILE_UPLOAD_TIMEOUT_MS } from './consts' +import { LogEntry } from './logger' +import { getBuildStepIndex, tarFileStream } from './utils' +import { + BuildStatusReason, + TemplateBuildStatus, + TemplateBuildStatusResponse, + TemplateTag, + TemplateTagInfo, +} from './types' + +type RequestBuildInput = { + name: string + tags?: string[] + cpuCount: number + memoryMB: number +} + +type GetFileUploadLinkInput = { + templateID: string + filesHash: string +} + +type TriggerBuildInput = { + templateID: string + buildID: string + template: TriggerBuildTemplate +} + +type GetBuildStatusInput = { + templateID: string + buildID: string + logsOffset?: number +} + +type CheckAliasExistsInput = { + alias: string +} + +type ApiBuildStatusResponse = components['schemas']['TemplateBuildInfo'] + +export type TriggerBuildTemplate = components['schemas']['TemplateBuildStartV2'] + +export async function requestBuild( + client: ApiClient, + { name, tags, cpuCount, memoryMB }: RequestBuildInput, + signal?: AbortSignal +) { + const requestBuildRes = await client.api.POST('/v3/templates', { + body: { + name, + tags, + cpuCount, + memoryMB, + }, + signal, + }) + + const error = handleApiError(requestBuildRes, BuildError) + if (error) { + throw error + } + + if (!requestBuildRes.data) { + throw new BuildError('Failed to request build') + } + + return requestBuildRes.data +} + +export async function getFileUploadLink( + client: ApiClient, + { templateID, filesHash }: GetFileUploadLinkInput, + stackTrace?: string, + signal?: AbortSignal +) { + const fileUploadLinkRes = await client.api.GET( + '/templates/{templateID}/files/{hash}', + { + params: { + path: { + templateID, + hash: filesHash, + }, + }, + signal, + } + ) + + const error = handleApiError(fileUploadLinkRes, FileUploadError, stackTrace) + if (error) { + throw error + } + + if (!fileUploadLinkRes.data) { + throw new FileUploadError('Failed to get file upload link', stackTrace) + } + + return fileUploadLinkRes.data +} + +export async function uploadFile( + options: { + fileName: string + fileContextPath: string + url: string + ignorePatterns: string[] + resolveSymlinks: boolean + gzip: boolean + }, + stackTrace: string | undefined, + // Uploads (PUT to S3 presigned URL) can take a long time for large + // archives — the 60s API default would break them, so we use a 1-hour + // upload default (`FILE_UPLOAD_TIMEOUT_MS`) when `requestTimeoutMs` is + // not supplied. Pass `requestTimeoutMs` (or an `AbortSignal.timeout(ms)` + // via `signal`) to override. + abortOpts?: { signal?: AbortSignal; requestTimeoutMs?: number } +) { + const { + fileName, + url, + fileContextPath, + ignorePatterns, + resolveSymlinks, + gzip, + } = options + // Spool the archive to a temporary file and stream it from disk instead of + // buffering in memory. S3 presigned PUT URLs reject Transfer-Encoding: + // chunked with 501 NotImplemented (see e2b-dev/e2b#1243), so the upload + // sends an explicit Content-Length, which fetch honors for stream bodies. + // The spooled file deletes itself once the stream is consumed, so there is + // no cleanup to manage here. The Python SDK takes the same approach + // (build_api.py:upload_file). + let uploadStream: Readable | undefined + try { + // Dynamically import so the browser bundle doesn't pull in node:stream. + const { Readable } = + await dynamicImport('node:stream') + + const tar = await tarFileStream( + fileName, + fileContextPath, + ignorePatterns, + resolveSymlinks, + gzip + ) + uploadStream = tar.stream + + const res = await fetch(url, { + method: 'PUT', + body: Readable.toWeb(uploadStream) as ReadableStream, + headers: { + 'Content-Length': tar.size.toString(), + }, + // Streaming request bodies require half-duplex mode. + duplex: 'half', + signal: buildRequestSignal( + abortOpts?.requestTimeoutMs ?? FILE_UPLOAD_TIMEOUT_MS, + abortOpts?.signal + ), + } as RequestInit) + + if (!res.ok) { + throw new FileUploadError( + `Failed to upload file: ${res.statusText}`, + stackTrace + ) + } + } catch (error) { + // Ensure the spooled archive is removed even if fetch never consumed the + // stream (e.g. it threw before reading the body). Destroying the stream + // fires its `close` handler, which deletes the temp file. + uploadStream?.destroy() + if (error instanceof FileUploadError) { + throw error + } + throw new FileUploadError(`Failed to upload file: ${error}`, stackTrace) + } +} + +export async function triggerBuild( + client: ApiClient, + { templateID, buildID, template }: TriggerBuildInput, + signal?: AbortSignal +) { + const triggerBuildRes = await client.api.POST( + '/v2/templates/{templateID}/builds/{buildID}', + { + params: { + path: { + templateID, + buildID, + }, + }, + body: template, + signal, + } + ) + + const error = handleApiError(triggerBuildRes, BuildError) + if (error) { + throw error + } +} + +function mapLogEntry( + entry: ApiBuildStatusResponse['logEntries'][number] +): LogEntry { + return new LogEntry(new Date(entry.timestamp), entry.level, entry.message) +} + +function mapBuildStatusReason( + reason: ApiBuildStatusResponse['reason'] +): BuildStatusReason | undefined { + if (!reason) { + return undefined + } + return { + message: reason.message, + step: reason.step, + logEntries: (reason.logEntries ?? []).map(mapLogEntry), + } +} + +export async function getBuildStatus( + client: ApiClient, + { templateID, buildID, logsOffset }: GetBuildStatusInput, + signal?: AbortSignal +): Promise { + const buildStatusRes = await client.api.GET( + '/templates/{templateID}/builds/{buildID}/status', + { + params: { + path: { + templateID, + buildID, + }, + query: { + logsOffset, + }, + }, + signal, + } + ) + + const error = handleApiError(buildStatusRes, BuildError) + if (error) { + throw error + } + + if (!buildStatusRes.data) { + throw new BuildError('Failed to get build status') + } + + return { + buildID: buildStatusRes.data.buildID, + templateID: buildStatusRes.data.templateID, + status: buildStatusRes.data.status, + logEntries: buildStatusRes.data.logEntries.map(mapLogEntry), + logs: buildStatusRes.data.logs, + reason: mapBuildStatusReason(buildStatusRes.data.reason), + } +} + +export async function checkAliasExists( + client: ApiClient, + { alias }: CheckAliasExistsInput, + signal?: AbortSignal +): Promise { + const aliasRes = await client.api.GET('/templates/aliases/{alias}', { + params: { + path: { + alias, + }, + }, + signal, + }) + + // If we get a NotFound, the alias doesn't exist + if (aliasRes.response.status === 404) { + return false + } + + // If we get a Forbidden, alias exists, but you are not owner + if (aliasRes.response.status === 403) { + return true + } + + // Handle other errors + const error = handleApiError(aliasRes, TemplateError) + if (error) { + throw error + } + + // If we get Ok with data, you are owner and the alias exists + return aliasRes.data !== undefined +} + +export async function waitForBuildFinish( + client: ApiClient, + { + templateID, + buildID, + onBuildLogs, + logsRefreshFrequency, + stackTraces, + signal, + requestTimeoutMs, + }: { + templateID: string + buildID: string + onBuildLogs?: (logEntry: LogEntry) => void + logsRefreshFrequency: number + stackTraces: (string | undefined)[] + signal?: AbortSignal + requestTimeoutMs?: number + } +): Promise { + let logsOffset = 0 + let status: TemplateBuildStatus = 'building' + + const pollStatus = async (): Promise => { + const buildStatus = await getBuildStatus( + client, + { + templateID, + buildID, + logsOffset, + }, + buildRequestSignal(requestTimeoutMs, signal) + ) + + logsOffset += buildStatus.logEntries.length + buildStatus.logEntries.forEach((logEntry) => onBuildLogs?.(logEntry)) + + return buildStatus + } + + while (status === 'building' || status === 'waiting') { + signal?.throwIfAborted() + + const buildStatus = await pollStatus() + + status = buildStatus.status + switch (status) { + case 'ready': + case 'error': { + // The status endpoint returns at most 100 log entries per call, so + // the terminal response may not include the last logs — keep + // fetching until they are drained. + let tailStatus = buildStatus + while (tailStatus.logEntries.length > 0) { + signal?.throwIfAborted() + tailStatus = await pollStatus() + } + + if (status === 'ready') { + return + } + + let stackError: string | undefined + if (buildStatus.reason?.step !== undefined) { + const step = getBuildStepIndex( + buildStatus.reason.step, + stackTraces.length + ) + stackError = stackTraces[step] + } + + throw new BuildError( + buildStatus?.reason?.message ?? 'Unknown error', + stackError + ) + } + case 'waiting': { + break + } + } + + // Wait for a short period before checking the status again. Abort is + // observed on the next iteration via `signal?.throwIfAborted()`. + await new Promise((resolve) => setTimeout(resolve, logsRefreshFrequency)) + } + + throw new BuildError('Unknown build error occurred.') +} + +export async function assignTags( + client: ApiClient, + { targetName, tags }: { targetName: string; tags: string[] }, + signal?: AbortSignal +): Promise { + const res = await client.api.POST('/templates/tags', { + body: { target: targetName, tags }, + signal, + }) + + const error = handleApiError(res, TemplateError) + if (error) { + throw error + } + + if (!res.data) { + throw new TemplateError('Failed to assign tags') + } + + return { + buildId: res.data.buildID, + tags: res.data.tags, + } +} + +export async function removeTags( + client: ApiClient, + { name, tags }: { name: string; tags: string[] }, + signal?: AbortSignal +): Promise { + const res = await client.api.DELETE('/templates/tags', { + body: { name, tags }, + signal, + }) + + const error = handleApiError(res, TemplateError) + if (error) { + throw error + } +} + +export async function getTemplateTags( + client: ApiClient, + { templateID }: { templateID: string }, + signal?: AbortSignal +): Promise { + const res = await client.api.GET('/templates/{templateID}/tags', { + params: { + path: { + templateID, + }, + }, + signal, + }) + + const error = handleApiError(res, TemplateError) + if (error) { + throw error + } + + if (!res.data) { + throw new TemplateError('Failed to get template tags') + } + + return res.data.map((item: components['schemas']['TemplateTag']) => ({ + tag: item.tag, + buildId: item.buildID, + createdAt: new Date(item.createdAt), + })) +} diff --git a/packages/js-sdk/src/template/consts.ts b/packages/js-sdk/src/template/consts.ts new file mode 100644 index 0000000..a36e8eb --- /dev/null +++ b/packages/js-sdk/src/template/consts.ts @@ -0,0 +1,50 @@ +/** + * Special step name for the finalization phase of template building. + * This is the last step that runs after all user-defined instructions. + * @internal + */ +export const FINALIZE_STEP_NAME = 'finalize' + +/** + * Special step name for the base image phase of template building. + * This is the first step that sets up the base image. + * @internal + */ +export const BASE_STEP_NAME = 'base' + +/** + * Stack trace depth for capturing caller information. + * + * Depth levels: + * 1. Template function + * 2. TemplateBase class + * 3. Caller method (e.g., copy(), fromImage(), etc.) + * + * This depth is used to determine the original caller's location + * for stack traces. + * @internal + */ +export const STACK_TRACE_DEPTH = 3 + +/** + * Default setting for whether to resolve symbolic links when copying files. + * When false, symlinks are copied as symlinks rather than following them. + * @internal + */ +export const RESOLVE_SYMLINKS = false + +/** + * Default setting for whether to gzip files when copying them into the + * template. When true, the upload archive is gzipped before being uploaded. + * @internal + */ +export const GZIP = true + +/** + * Default per-request timeout (in milliseconds) for the file-upload phase + * (PUT to S3 presigned URL) when the caller hasn't supplied + * `requestTimeoutMs`. Large archives can take well over the 60s API + * default, so we use a generous 1-hour bound here. + * @internal + */ +export const FILE_UPLOAD_TIMEOUT_MS = 3_600_000 diff --git a/packages/js-sdk/src/template/dockerfileParser.ts b/packages/js-sdk/src/template/dockerfileParser.ts new file mode 100644 index 0000000..6ed62b5 --- /dev/null +++ b/packages/js-sdk/src/template/dockerfileParser.ts @@ -0,0 +1,316 @@ +import { CopyItem } from './types' +import { + Argument, + DockerfileParser, + Instruction as DockerfileInstruction, + ModifiableInstruction, +} from 'dockerfile-ast' +import fs from 'node:fs' +import { ReadyCmd, waitForTimeout } from './readycmd' + +export interface DockerfileParseResult { + baseImage: string +} + +interface DockerfileFinalParserInterface {} + +export interface DockerfileParserInterface { + setWorkdir(workdir: string): DockerfileParserInterface + setUser(user: string): DockerfileParserInterface + setEnvs(envs: Record): DockerfileParserInterface + runCmd( + commandOrCommands: string | string[], + options?: { user?: string } + ): DockerfileParserInterface + copy( + src: string, + dest: string, + options?: { forceUpload?: true; user?: string; mode?: number } + ): DockerfileParserInterface + copyItems( + items: CopyItem[], + options?: { forceUpload?: true; user?: string; mode?: number } + ): DockerfileParserInterface + setStartCmd( + startCommand: string, + readyCommand: string | ReadyCmd + ): DockerfileFinalParserInterface +} + +/** + * Parse a Dockerfile and convert it to Template SDK format + * + * @param dockerfileContentOrPath Either the Dockerfile content as a string, + * or a path to a Dockerfile file + * @param templateBuilder Interface providing template builder methods + * @returns Parsed Dockerfile result with base image and instructions + */ +export function parseDockerfile( + dockerfileContentOrPath: string, + templateBuilder: DockerfileParserInterface +): DockerfileParseResult { + // Check if input is a file path that exists + let dockerfileContent: string + try { + if ( + fs.existsSync(dockerfileContentOrPath) && + fs.statSync(dockerfileContentOrPath).isFile() + ) { + // Read the file content + dockerfileContent = fs.readFileSync(dockerfileContentOrPath, 'utf-8') + } else { + // Treat as content directly + dockerfileContent = dockerfileContentOrPath + } + } catch { + // If there's any error checking the file, treat as content + dockerfileContent = dockerfileContentOrPath + } + + const dockerfile = DockerfileParser.parse(dockerfileContent) + const instructions = dockerfile.getInstructions() + + // Check for multi-stage builds + const fromInstructions = instructions.filter( + (instruction) => instruction.getKeyword() === 'FROM' + ) + + if (fromInstructions.length > 1) { + throw new Error('Multi-stage Dockerfiles are not supported') + } + + if (fromInstructions.length === 0) { + throw new Error('Dockerfile must contain a FROM instruction') + } + + // Set the base image from the first FROM instruction + const fromInstruction = fromInstructions[0] + const argumentsData = fromInstruction.getArguments() + let baseImage = 'e2bdev/base' // default fallback + let userChanged = false + let workdirChanged = false + if (argumentsData && argumentsData.length > 0) { + baseImage = argumentsData[0].getValue() + } + + // Set the user and workdir to the Docker defaults + templateBuilder.setUser('root') + templateBuilder.setWorkdir('/') + + // Process all other instructions + for (const instruction of instructions) { + const keyword = instruction.getKeyword() + + switch (keyword) { + case 'FROM': + // Already handled above + break + + case 'RUN': + handleRunInstruction(instruction, templateBuilder) + break + + case 'COPY': + case 'ADD': + handleCopyInstruction( + instruction as ModifiableInstruction, + templateBuilder + ) + break + + case 'WORKDIR': + handleWorkdirInstruction(instruction, templateBuilder) + workdirChanged = true + break + + case 'USER': + handleUserInstruction(instruction, templateBuilder) + userChanged = true + break + + case 'ENV': + case 'ARG': + handleEnvInstruction(instruction, templateBuilder) + break + + case 'EXPOSE': + // EXPOSE is not directly supported in our SDK, so we'll skip it + break + + case 'VOLUME': + // VOLUME is not directly supported in our SDK, so we'll skip it + break + + case 'CMD': + case 'ENTRYPOINT': + handleCmdEntrypointInstruction(instruction, templateBuilder) + break + + default: + console.warn(`Unsupported instruction: ${keyword}`) + break + } + } + + // Set the user and workdir to the E2B defaults + if (!userChanged) { + templateBuilder.setUser('user') + } + if (!workdirChanged) { + templateBuilder.setWorkdir('/home/user') + } + + return { + baseImage, + } +} + +function handleRunInstruction( + instruction: DockerfileInstruction, + templateBuilder: DockerfileParserInterface +): void { + const argumentsData = instruction.getArguments() + if (argumentsData && argumentsData.length > 0) { + const command = argumentsData + .map((arg: Argument) => arg.getValue()) + .join(' ') + templateBuilder.runCmd(command) + } +} + +function handleCopyInstruction( + instruction: ModifiableInstruction, + templateBuilder: DockerfileParserInterface +): void { + const argumentsData = instruction.getArguments() + if (argumentsData && argumentsData.length >= 2) { + const dest = argumentsData[argumentsData.length - 1].getValue() + const sources = argumentsData + .slice(0, -1) + .map((arg: Argument) => arg.getValue()) + + let user: string | undefined + const flags = instruction.getFlags() + const chownFlag = flags.find((flag) => flag.getName() === 'chown') + if (chownFlag) { + user = chownFlag.getValue() ?? undefined + } + + for (const src of sources) { + templateBuilder.copy(src, dest, { user }) + } + } +} + +function handleWorkdirInstruction( + instruction: DockerfileInstruction, + templateBuilder: DockerfileParserInterface +): void { + const argumentsData = instruction.getArguments() + if (argumentsData && argumentsData.length > 0) { + const workdir = argumentsData[0].getValue() + templateBuilder.setWorkdir(workdir) + } +} + +function handleUserInstruction( + instruction: DockerfileInstruction, + templateBuilder: DockerfileParserInterface +): void { + const argumentsData = instruction.getArguments() + if (argumentsData && argumentsData.length > 0) { + const user = argumentsData[0].getValue() + templateBuilder.setUser(user) + } +} + +function handleEnvInstruction( + instruction: DockerfileInstruction, + templateBuilder: DockerfileParserInterface +): void { + const argumentsData = instruction.getArguments() + const keyword = instruction.getKeyword() + + if (argumentsData && argumentsData.length >= 1) { + const envVars: Record = {} + + if (argumentsData.length === 2) { + // ENV key value format OR multiple key=value pairs (from line continuation) + const firstArg = argumentsData[0].getValue() + const secondArg = argumentsData[1].getValue() + + // Check if both arguments contain '=' (multiple key=value pairs) + if (firstArg.includes('=') && secondArg.includes('=')) { + // Both are key=value pairs (line continuation) + for (const arg of argumentsData) { + const envString = arg.getValue() + const equalIndex = envString.indexOf('=') + if (equalIndex > 0) { + const key = envString.substring(0, equalIndex) + const value = envString.substring(equalIndex + 1) + envVars[key] = value + } + } + } else { + // Traditional ENV key value format + envVars[firstArg] = secondArg + } + } else if (argumentsData.length === 1) { + // ENV/ARG key=value format (single argument) or ARG key (without default) + const envString = argumentsData[0].getValue() + + // Check if it's a simple key=value or just a key (for ARG without default) + const equalIndex = envString.indexOf('=') + if (equalIndex > 0) { + const key = envString.substring(0, equalIndex) + const value = envString.substring(equalIndex + 1) + envVars[key] = value + } else if (keyword === 'ARG' && envString.trim()) { + // ARG without default value - set as empty ENV + const key = envString.trim() + envVars[key] = '' + } + } else { + // Multiple arguments (from line continuation with backslashes) + for (const arg of argumentsData) { + const envString = arg.getValue() + const equalIndex = envString.indexOf('=') + if (equalIndex > 0) { + const key = envString.substring(0, equalIndex) + const value = envString.substring(equalIndex + 1) + envVars[key] = value + } else if (keyword === 'ARG') { + // ARG without default value + const key = envString + envVars[key] = '' + } + } + } + + // Call setEnvs once with all environment variables from this instruction + if (Object.keys(envVars).length > 0) { + templateBuilder.setEnvs(envVars) + } + } +} + +function handleCmdEntrypointInstruction( + instruction: DockerfileInstruction, + templateBuilder: DockerfileParserInterface +): void { + const argumentsData = instruction.getArguments() + if (argumentsData && argumentsData.length > 0) { + let command = argumentsData.map((arg: Argument) => arg.getValue()).join(' ') + + try { + const parsedCommand = JSON.parse(command) + if (Array.isArray(parsedCommand)) { + command = parsedCommand.join(' ') + } + } catch { + // Do nothing + } + + templateBuilder.setStartCmd(command, waitForTimeout(20_000)) + } +} diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts new file mode 100644 index 0000000..7e75c92 --- /dev/null +++ b/packages/js-sdk/src/template/index.ts @@ -0,0 +1,1375 @@ +import type { PathLike } from 'node:fs' +import { ApiClient } from '../api' +import { ConnectionConfig, ConnectionOpts } from '../connectionConfig' +import { BuildError, InvalidArgumentError } from '../errors' +import { runtime, shellQuote } from '../utils' +import { + assignTags, + checkAliasExists, + getTemplateTags, + removeTags, + getBuildStatus, + getFileUploadLink, + requestBuild, + triggerBuild, + TriggerBuildTemplate, + uploadFile, + waitForBuildFinish, +} from './buildApi' +import { GZIP, RESOLVE_SYMLINKS, STACK_TRACE_DEPTH } from './consts' +import { parseDockerfile } from './dockerfileParser' +import { LogEntry, LogEntryEnd, LogEntryStart } from './logger' +import { ReadyCmd, waitForFile } from './readycmd' +import { + BuildInfo, + BuildOptions, + CopyItem, + GetBuildStatusOptions, + Instruction, + InstructionType, + McpServerName, + RegistryConfig, + TemplateBuilder, + TemplateBuildStatusResponse, + TemplateClass, + TemplateFinal, + TemplateFromImage, + TemplateOptions, + TemplateTag, + TemplateTagInfo, +} from './types' +import { + calculateFilesHash, + getCallerDirectory, + getCallerFrame, + normalizeBuildArguments, + padOctal, + readDockerignore, + readGCPServiceAccountJSON, + validateRelativePath, +} from './utils' + +/** + * Base class for building E2B sandbox templates. + */ +export class TemplateBase + implements TemplateFromImage, TemplateBuilder, TemplateFinal +{ + private defaultBaseImage: string = 'e2bdev/base' + private baseImage: string | undefined = this.defaultBaseImage + private baseTemplate: string | undefined = undefined + private registryConfig: RegistryConfig | undefined = undefined + private startCmd: string | undefined = undefined + private readyCmd: string | undefined = undefined + // Force the whole template to be rebuilt + private force: boolean = false + // Force the next layer to be rebuilt + private forceNextLayer: boolean = false + private instructions: Instruction[] = [] + private fileContextPath: PathLike = + runtime === 'browser' ? '.' : (getCallerDirectory(STACK_TRACE_DEPTH) ?? '.') + private fileIgnorePatterns: string[] = [] + private logsRefreshFrequency: number = 200 + private stackTraces: (string | undefined)[] = [] + private stackTracesEnabled: boolean = true + private stackTracesOverride: string | undefined = undefined + + constructor(options?: TemplateOptions) { + this.fileContextPath = options?.fileContextPath ?? this.fileContextPath + this.fileIgnorePatterns = + options?.fileIgnorePatterns ?? this.fileIgnorePatterns + } + + /** + * Convert a template to JSON representation. + * + * @param template The template to convert + * @param computeHashes Whether to compute file hashes for cache invalidation + * @returns JSON string representation of the template + */ + static toJSON( + template: TemplateClass, + computeHashes: boolean = true + ): Promise { + return (template as TemplateBase).toJSON(computeHashes) + } + + /** + * Convert a template to Dockerfile format. + * Note: Templates based on other E2B templates cannot be converted to Dockerfile. + * + * @param template The template to convert + * @returns Dockerfile string representation + * @throws Error if the template is based on another E2B template + */ + static toDockerfile(template: TemplateClass): string { + return (template as TemplateBase).toDockerfile() + } + + /** + * Build and deploy a template to E2B infrastructure. + * + * @param template The template to build + * @param name Template name in 'name' or 'name:tag' format + * @param options Optional build configuration options + * + * @example + * ```ts + * const template = Template().fromPythonImage('3') + * + * // Build with single tag in name + * await Template.build(template, 'my-python-env:v1.0') + * + * // Build with multiple tags + * await Template.build(template, 'my-python-env', { tags: ['v1.0', 'stable'] }) + * ``` + */ + static async build( + template: TemplateClass, + name: string, + options?: Omit + ): Promise + /** + * Build and deploy a template to E2B infrastructure. + * + * @param template The template to build + * @param options Build configuration options with alias (deprecated) + * + * @deprecated Use the overload with `name` parameter instead. + * @example + * ```ts + * // Deprecated: + * await Template.build(template, { alias: 'my-python-env' }) + * + * // Use instead: + * await Template.build(template, 'my-python-env:v1.0') + * ``` + */ + static async build( + template: TemplateClass, + options: BuildOptions + ): Promise + static async build( + template: TemplateClass, + nameOrOptions: string | BuildOptions, + options?: Omit + ): Promise { + const { name, buildOptions } = normalizeBuildArguments( + nameOrOptions, + options + ) + + try { + buildOptions.onBuildLogs?.(new LogEntryStart(new Date(), 'Build started')) + const baseTemplate = template as TemplateBase + + const config = new ConnectionConfig(buildOptions) + const client = new ApiClient(config) + + const data = await baseTemplate.build(client, config, name, buildOptions) + + buildOptions.onBuildLogs?.( + new LogEntry(new Date(), 'info', 'Waiting for logs...') + ) + + await waitForBuildFinish(client, { + templateID: data.templateId, + buildID: data.buildId, + onBuildLogs: buildOptions.onBuildLogs, + logsRefreshFrequency: baseTemplate.logsRefreshFrequency, + stackTraces: baseTemplate.stackTraces, + signal: buildOptions.signal, + requestTimeoutMs: config.requestTimeoutMs, + }) + + return data + } finally { + buildOptions.onBuildLogs?.(new LogEntryEnd(new Date(), 'Build finished')) + } + } + + /** + * Build and deploy a template to E2B infrastructure without waiting for completion. + * + * @param template The template to build + * @param name Template name in 'name' or 'name:tag' format + * @param options Optional build configuration options + * + * @example + * ```ts + * const template = Template().fromPythonImage('3') + * + * // Build with single tag in name + * const data = await Template.buildInBackground(template, 'my-python-env:v1.0') + * + * // Build with multiple tags + * const data = await Template.buildInBackground(template, 'my-python-env', { tags: ['v1.0', 'stable'] }) + * ``` + */ + static async buildInBackground( + template: TemplateClass, + name: string, + options?: Omit + ): Promise + /** + * Build and deploy a template to E2B infrastructure without waiting for completion. + * + * @param template The template to build + * @param options Build configuration options with alias (deprecated) + * + * @deprecated Use the overload with `name` parameter instead. + * @example + * ```ts + * // Deprecated: + * await Template.buildInBackground(template, { alias: 'my-python-env' }) + * + * // Use instead: + * await Template.buildInBackground(template, 'my-python-env:v1.0') + * ``` + */ + static async buildInBackground( + template: TemplateClass, + options: BuildOptions + ): Promise + static async buildInBackground( + template: TemplateClass, + nameOrOptions: string | BuildOptions, + options?: Omit + ): Promise { + const { name, buildOptions } = normalizeBuildArguments( + nameOrOptions, + options + ) + + const config = new ConnectionConfig(buildOptions) + const client = new ApiClient(config) + + return (template as TemplateBase).build(client, config, name, buildOptions) + } + + /** + * Get the status of a build. + * + * @param data Build identifiers + * @param options Authentication options + * + * @example + * ```ts + * const status = await Template.getBuildStatus(data, { logsOffset: 0 }) + * ``` + */ + static async getBuildStatus( + data: Pick, + options?: GetBuildStatusOptions + ): Promise { + const config = new ConnectionConfig(options) + const client = new ApiClient(config) + + return await getBuildStatus( + client, + { + templateID: data.templateId, + buildID: data.buildId, + logsOffset: options?.logsOffset ?? 0, + }, + config.getSignal(undefined, options?.signal) + ) + } + + /** + * Check if a template with the given name exists. + * + * @param name Template name to check + * @param options Authentication options + * @returns True if the name exists, false otherwise + * + * @example + * ```ts + * const exists = await Template.exists('my-python-env') + * if (exists) { + * console.log('Template exists!') + * } + * ``` + */ + static async exists( + name: string, + options?: ConnectionOpts + ): Promise { + return TemplateBase.aliasExists(name, options) + } + + /** + * Check if a template with the given alias exists. + * + * @param alias Template alias to check + * @param options Authentication options + * @returns True if the alias exists, false otherwise + * + * @deprecated Use `exists` instead. + * @example + * ```ts + * const exists = await Template.aliasExists('my-python-env') + * if (exists) { + * console.log('Template exists!') + * } + * ``` + */ + static async aliasExists( + alias: string, + options?: ConnectionOpts + ): Promise { + const config = new ConnectionConfig(options) + const client = new ApiClient(config) + + return checkAliasExists( + client, + { alias }, + config.getSignal(undefined, options?.signal) + ) + } + + /** + * Assign tag(s) to an existing template build. + * + * @param targetName Template name in 'name:tag' format (the source build to tag from) + * @param tags Tag or tags to assign + * @param options Authentication options + * @returns Tag info with buildId and assigned tags + * + * @example + * ```ts + * // Assign a single tag + * await Template.assignTags('my-template:v1.0', 'production') + * + * // Assign multiple tags + * await Template.assignTags('my-template:v1.0', ['production', 'stable']) + * ``` + */ + static async assignTags( + targetName: string, + tags: string | string[], + options?: ConnectionOpts + ): Promise { + const config = new ConnectionConfig(options) + const client = new ApiClient(config) + const normalizedTags = Array.isArray(tags) ? tags : [tags] + return assignTags( + client, + { targetName, tags: normalizedTags }, + config.getSignal(undefined, options?.signal) + ) + } + + /** + * Remove tag(s) from a template. + * + * @param name Template name + * @param tags Tag or tags to remove + * @param options Authentication options + * + * @example + * ```ts + * // Remove a single tag + * await Template.removeTags('my-template', 'production') + * + * // Remove multiple tags from a template + * await Template.removeTags('my-template', ['production', 'staging']) + * ``` + */ + static async removeTags( + name: string, + tags: string | string[], + options?: ConnectionOpts + ): Promise { + const config = new ConnectionConfig(options) + const client = new ApiClient(config) + const normalizedTags = Array.isArray(tags) ? tags : [tags] + return removeTags( + client, + { name, tags: normalizedTags }, + config.getSignal(undefined, options?.signal) + ) + } + + /** + * Get all tags for a template. + * + * @param templateId Template ID or name + * @param options Authentication options + * @returns Array of tag details including tag name, buildId, and creation date + * + * @example + * ```ts + * const tags = await Template.getTags('my-template') + * for (const tag of tags) { + * console.log(`Tag: ${tag.tag}, Build: ${tag.buildId}, Created: ${tag.createdAt}`) + * } + * ``` + */ + static async getTags( + templateId: string, + options?: ConnectionOpts + ): Promise { + const config = new ConnectionConfig(options) + const client = new ApiClient(config) + return getTemplateTags( + client, + { templateID: templateId }, + config.getSignal(undefined, options?.signal) + ) + } + + fromDebianImage(variant: string = 'stable'): TemplateBuilder { + return this.fromImage(`debian:${variant}`) + } + + fromUbuntuImage(variant: string = 'latest'): TemplateBuilder { + return this.fromImage(`ubuntu:${variant}`) + } + + fromPythonImage(version: string = '3'): TemplateBuilder { + return this.fromImage(`python:${version}`) + } + + fromNodeImage(variant: string = 'lts'): TemplateBuilder { + return this.fromImage(`node:${variant}`) + } + + fromBunImage(variant: string = 'latest'): TemplateBuilder { + return this.fromImage(`oven/bun:${variant}`) + } + + fromBaseImage(): TemplateBuilder { + return this.fromImage(this.defaultBaseImage) + } + + fromImage( + baseImage: string, + credentials?: { username: string; password: string } + ): TemplateBuilder { + // Validate before mutating the builder. + if (credentials && (!credentials.username || !credentials.password)) { + throw new InvalidArgumentError( + 'Both username and password are required when providing registry credentials', + getCallerFrame(STACK_TRACE_DEPTH - 1) + ) + } + + this.baseImage = baseImage + this.baseTemplate = undefined + + // Set the registry config if provided + if (credentials) { + this.registryConfig = { + type: 'registry', + username: credentials.username, + password: credentials.password, + } + } + + // If we should force the next layer and it's a FROM command, invalidate whole template + if (this.forceNextLayer) { + this.force = true + } + + this.collectStackTrace() + return this + } + + fromTemplate(template: string): TemplateBuilder { + this.baseTemplate = template + this.baseImage = undefined + + // If we should force the next layer and it's a FROM command, invalidate whole template + if (this.forceNextLayer) { + this.force = true + } + + this.collectStackTrace() + return this + } + + fromDockerfile(dockerfileContentOrPath: string): TemplateBuilder { + const { baseImage } = this.runInStackTraceOverrideContext( + () => parseDockerfile(dockerfileContentOrPath, this), + // -1 as we're going up the call stack from the parseDockerfile function + getCallerFrame(STACK_TRACE_DEPTH - 1) + ) + this.baseImage = baseImage + this.baseTemplate = undefined + + // If we should force the next layer and it's a FROM command, invalidate whole template + if (this.forceNextLayer) { + this.force = true + } + + this.collectStackTrace() + return this + } + + fromAWSRegistry( + image: string, + credentials: { + accessKeyId: string + secretAccessKey: string + region: string + } + ): TemplateBuilder { + this.baseImage = image + this.baseTemplate = undefined + + // Set the registry config if provided + this.registryConfig = { + type: 'aws', + awsAccessKeyId: credentials.accessKeyId, + awsSecretAccessKey: credentials.secretAccessKey, + awsRegion: credentials.region, + } + + // If we should force the next layer and it's a FROM command, invalidate whole template + if (this.forceNextLayer) { + this.force = true + } + + this.collectStackTrace() + return this + } + + fromGCPRegistry( + image: string, + credentials: { + serviceAccountJSON: string | object + } + ): TemplateBuilder { + this.baseImage = image + this.baseTemplate = undefined + + // Set the registry config if provided + this.registryConfig = { + type: 'gcp', + serviceAccountJson: readGCPServiceAccountJSON( + this.fileContextPath.toString(), + credentials.serviceAccountJSON + ), + } + + // If we should force the next layer and it's a FROM command, invalidate whole template + if (this.forceNextLayer) { + this.force = true + } + + this.collectStackTrace() + return this + } + + copy( + src: PathLike | PathLike[], + dest: PathLike, + options?: { + forceUpload?: true + user?: string + mode?: number + resolveSymlinks?: boolean + gzip?: boolean + } + ): TemplateBuilder { + if (runtime === 'browser') { + throw new Error('Browser runtime is not supported for copy') + } + + const srcs = Array.isArray(src) ? src : [src] + const stackTrace = getCallerFrame(STACK_TRACE_DEPTH - 1) + + for (const src of srcs) { + const srcString = src.toString() + + // Validate that the source path is a relative path within the context directory + validateRelativePath(srcString, stackTrace) + + const args = [ + srcString, + dest.toString(), + options?.user ?? '', + options?.mode ? padOctal(options.mode) : '', + ] + + this.instructions.push({ + type: InstructionType.COPY, + args, + force: options?.forceUpload || this.forceNextLayer, + forceUpload: options?.forceUpload, + resolveSymlinks: options?.resolveSymlinks, + gzip: options?.gzip, + }) + + // Collect one stack trace per pushed instruction so build steps stay + // aligned with their stack traces when copying multiple sources + this.collectStackTrace() + } + + return this + } + + copyItems(items: CopyItem[]): TemplateBuilder { + if (runtime === 'browser') { + throw new Error('Browser runtime is not supported for copyItems') + } + + // Stack trace that will be used to re-throw the error with + const stackTrace = getCallerFrame(STACK_TRACE_DEPTH - 1) + + // Use the override so each copied item collects this stack trace, + // keeping build steps aligned with their stack traces + this.runInStackTraceOverrideContext(() => { + for (const item of items) { + try { + this.copy(item.src, item.dest, { + forceUpload: item.forceUpload, + user: item.user, + mode: item.mode, + resolveSymlinks: item.resolveSymlinks, + gzip: item.gzip, + }) + } catch (error) { + const copyError = error as Error + copyError.stack = stackTrace + throw copyError + } + } + }, stackTrace) + + return this + } + + remove( + path: PathLike | PathLike[], + options?: { force?: boolean; recursive?: boolean; user?: string } + ): TemplateBuilder { + const paths = Array.isArray(path) ? path : [path] + const args = ['rm'] + if (options?.recursive) { + args.push('-r') + } + if (options?.force) { + args.push('-f') + } + args.push(...paths.map((p) => shellQuote(p.toString()))) + return this.runInNewStackTraceContext(() => + this.runCmd(args.join(' '), { user: options?.user }) + ) + } + + rename( + src: PathLike, + dest: PathLike, + options?: { force?: boolean; user?: string } + ): TemplateBuilder { + const args = ['mv', shellQuote(src.toString()), shellQuote(dest.toString())] + if (options?.force) { + args.push('-f') + } + return this.runInNewStackTraceContext(() => + this.runCmd(args.join(' '), { user: options?.user }) + ) + } + + makeDir( + path: PathLike | PathLike[], + options?: { mode?: number; user?: string } + ): TemplateBuilder { + const paths = Array.isArray(path) ? path : [path] + const args = ['mkdir', '-p'] + if (options?.mode) { + args.push(`-m ${padOctal(options.mode)}`) + } + args.push(...paths.map((p) => shellQuote(p.toString()))) + return this.runInNewStackTraceContext(() => + this.runCmd(args.join(' '), { user: options?.user }) + ) + } + + makeSymlink( + src: PathLike, + dest: PathLike, + options?: { user?: string; force?: boolean } + ): TemplateBuilder { + const args = ['ln', '-s'] + if (options?.force) { + args.push('-f') + } + args.push(shellQuote(src.toString()), shellQuote(dest.toString())) + return this.runInNewStackTraceContext(() => + this.runCmd(args.join(' '), { user: options?.user }) + ) + } + + runCmd(command: string, options?: { user?: string }): TemplateBuilder + runCmd(commands: string[], options?: { user?: string }): TemplateBuilder + runCmd( + commandOrCommands: string | string[], + options?: { user?: string } + ): TemplateBuilder { + const cmds = Array.isArray(commandOrCommands) + ? commandOrCommands + : [commandOrCommands] + + const args = [cmds.join(' && ')] + if (options?.user) { + args.push(options.user) + } + + this.instructions.push({ + type: InstructionType.RUN, + args, + force: this.forceNextLayer, + }) + + this.collectStackTrace() + return this + } + + setWorkdir(workdir: PathLike): TemplateBuilder { + this.instructions.push({ + type: InstructionType.WORKDIR, + args: [workdir.toString()], + force: this.forceNextLayer, + }) + + this.collectStackTrace() + return this + } + + setUser(user: string): TemplateBuilder { + this.instructions.push({ + type: InstructionType.USER, + args: [user], + force: this.forceNextLayer, + }) + + this.collectStackTrace() + return this + } + + pipInstall( + packages?: string | string[], + options?: { g?: boolean } + ): TemplateBuilder { + const g = options?.g ?? true + + const args = ['pip', 'install'] + const packageList = packages + ? Array.isArray(packages) + ? packages + : [packages] + : undefined + if (g === false) { + args.push('--user') + } + if (packageList) { + args.push(...packageList) + } else { + args.push('.') + } + + return this.runInNewStackTraceContext(() => + this.runCmd(args.join(' '), { + user: g ? 'root' : undefined, + }) + ) + } + + npmInstall( + packages?: string | string[], + options?: { g?: boolean; dev?: boolean } + ): TemplateBuilder { + const args = ['npm', 'install'] + const packageList = packages + ? Array.isArray(packages) + ? packages + : [packages] + : undefined + if (options?.g) { + args.push('-g') + } + if (options?.dev) { + args.push('--save-dev') + } + if (packageList) { + args.push(...packageList) + } + + return this.runInNewStackTraceContext(() => + this.runCmd(args.join(' '), { + user: options?.g ? 'root' : undefined, + }) + ) + } + + bunInstall( + packages?: string | string[], + options?: { g?: boolean; dev?: boolean } + ): TemplateBuilder { + const args = ['bun', 'install'] + const packageList = packages + ? Array.isArray(packages) + ? packages + : [packages] + : undefined + if (options?.g) { + args.push('-g') + } + if (options?.dev) { + args.push('--dev') + } + if (packageList) { + args.push(...packageList) + } + + return this.runInNewStackTraceContext(() => + this.runCmd(args.join(' '), { + user: options?.g ? 'root' : undefined, + }) + ) + } + + aptInstall( + packages: string | string[], + options?: { noInstallRecommends?: boolean; fixMissing?: boolean } + ): TemplateBuilder { + const packageList = Array.isArray(packages) ? packages : [packages] + return this.runInNewStackTraceContext(() => + this.runCmd( + [ + 'apt-get update', + `DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y ${options?.noInstallRecommends ? '--no-install-recommends ' : ''}${options?.fixMissing ? '--fix-missing ' : ''}${packageList.join( + ' ' + )}`, + ], + { user: 'root' } + ) + ) + } + + addMcpServer(servers: McpServerName | McpServerName[]): TemplateBuilder { + if (this.baseTemplate !== 'mcp-gateway') { + throw new BuildError( + 'MCP servers can only be added to mcp-gateway template', + getCallerFrame(STACK_TRACE_DEPTH - 1) + ) + } + + const serverList = Array.isArray(servers) ? servers : [servers] + return this.runInNewStackTraceContext(() => + this.runCmd(`mcp-gateway pull ${serverList.join(' ')}`, { + user: 'root', + }) + ) + } + + gitClone( + url: string, + path?: PathLike, + options?: { branch?: string; depth?: number; user?: string } + ): TemplateBuilder { + const args = ['git', 'clone', shellQuote(url)] + if (options?.branch) { + args.push(`--branch ${shellQuote(options.branch)}`) + args.push('--single-branch') + } + if (options?.depth) { + args.push(`--depth ${options.depth}`) + } + if (path) { + args.push(shellQuote(path.toString())) + } + + return this.runInNewStackTraceContext(() => + this.runCmd(args.join(' '), { user: options?.user }) + ) + } + + setStartCmd( + startCommand: string, + readyCommand: string | ReadyCmd + ): TemplateFinal { + this.startCmd = startCommand + + if (readyCommand instanceof ReadyCmd) { + this.readyCmd = readyCommand.getCmd() + } else { + this.readyCmd = readyCommand + } + + this.collectStackTrace() + return this + } + + setReadyCmd(readyCommand: string | ReadyCmd): TemplateFinal { + if (readyCommand instanceof ReadyCmd) { + this.readyCmd = readyCommand.getCmd() + } else { + this.readyCmd = readyCommand + } + + this.collectStackTrace() + return this + } + + setEnvs(envs: Record): TemplateBuilder { + if (Object.keys(envs).length === 0) { + return this + } + + this.instructions.push({ + type: InstructionType.ENV, + args: Object.entries(envs).flatMap(([key, value]) => [key, value]), + force: this.forceNextLayer, + }) + this.collectStackTrace() + return this + } + + skipCache(): this { + this.forceNextLayer = true + return this + } + + betaDevContainerPrebuild(devcontainerDirectory: string): TemplateBuilder { + if (this.baseTemplate !== 'devcontainer') { + throw new BuildError( + 'Devcontainers can only used in the devcontainer template', + getCallerFrame(STACK_TRACE_DEPTH - 1) + ) + } + + return this.runInNewStackTraceContext(() => { + return this.runCmd( + `devcontainer build --workspace-folder ${shellQuote(devcontainerDirectory)}`, + { user: 'root' } + ) + }) + } + + betaSetDevContainerStart(devcontainerDirectory: string): TemplateFinal { + if (this.baseTemplate !== 'devcontainer') { + throw new BuildError( + 'Devcontainers can only used in the devcontainer template', + getCallerFrame(STACK_TRACE_DEPTH - 1) + ) + } + + return this.runInNewStackTraceContext(() => { + const dir = shellQuote(devcontainerDirectory) + return this.setStartCmd( + `sudo devcontainer up --workspace-folder ${dir} && sudo /prepare-exec.sh ${dir} | sudo tee /devcontainer.sh > /dev/null && sudo chmod +x /devcontainer.sh && sudo touch /devcontainer.up`, + waitForFile('/devcontainer.up') + ) + }) + } + + /** + * Collect the current stack trace for debugging purposes. + * + * @param stackTracesDepth Depth to traverse in the call stack + * @returns this for method chaining + */ + private collectStackTrace(stackTracesDepth: number = STACK_TRACE_DEPTH) { + if (!this.stackTracesEnabled) { + return this + } + + if (this.stackTracesOverride) { + this.stackTraces.push(this.stackTracesOverride) + return this + } + + this.stackTraces.push(getCallerFrame(stackTracesDepth)) + return this + } + + /** + * Temporarily disable stack trace collection. + * + * @returns this for method chaining + */ + private disableStackTrace() { + this.stackTracesEnabled = false + return this + } + + /** + * Re-enable stack trace collection. + * + * @returns this for method chaining + */ + private enableStackTrace() { + this.stackTracesEnabled = true + return this + } + + /** + * Execute a function in a clean stack trace context. + * + * @param fn Function to execute + * @returns The result of the function + */ + private runInNewStackTraceContext(fn: () => T): T { + this.disableStackTrace() + let result: T + try { + result = fn() + } finally { + this.enableStackTrace() + } + this.collectStackTrace(STACK_TRACE_DEPTH + 1) + return result + } + + private runInStackTraceOverrideContext( + fn: () => T, + stackTraceOverride: string | undefined + ): T { + this.stackTracesOverride = stackTraceOverride + try { + return fn() + } finally { + this.stackTracesOverride = undefined + } + } + + /** + * Convert the template to JSON representation. + * + * @param computeHashes Whether to compute file hashes for COPY instructions + * @returns JSON string representation of the template + */ + private async toJSON(computeHashes: boolean): Promise { + let instructions = this.instructions + if (computeHashes) { + instructions = await this.instructionsWithHashes() + } + + return JSON.stringify(this.serialize(instructions), undefined, 2) + } + + /** + * Convert the template to Dockerfile format. + * + * Note: Only templates based on Docker images can be converted to Dockerfile. + * Templates based on other E2B templates cannot be converted because they + * may use features not available in standard Dockerfiles. + * + * @returns Dockerfile string representation + * @throws Error if template is based on another E2B template or has no base image + */ + private toDockerfile(): string { + if (this.baseTemplate !== undefined) { + throw new Error( + 'Cannot convert template built from another template to Dockerfile. ' + + 'Templates based on other templates can only be built using the E2B API.' + ) + } + + if (this.baseImage === undefined) { + throw new Error('No base image specified for template') + } + + let dockerfile = `FROM ${this.baseImage}\n` + for (const instruction of this.instructions) { + if (instruction.type === InstructionType.RUN) { + dockerfile += `RUN ${instruction.args[0]}\n` + continue + } + if (instruction.type === InstructionType.COPY) { + dockerfile += `COPY ${instruction.args[0]} ${instruction.args[1]}\n` + continue + } + if (instruction.type === InstructionType.ENV) { + const values: string[] = [] + for (let i = 0; i < instruction.args.length; i += 2) { + values.push(`${instruction.args[i]}=${instruction.args[i + 1]}`) + } + dockerfile += `ENV ${values.join(' ')}\n` + continue + } + dockerfile += `${instruction.type} ${instruction.args.join(' ')}\n` + } + if (this.startCmd) { + dockerfile += `ENTRYPOINT ${this.startCmd}\n` + } + return dockerfile + } + + /** + * Internal implementation of the template build process. + * + * @param client API client for communicating with E2B backend + * @param name Template name in 'name' or 'name:tag' format + * @param tags Additional tags to assign to the build + * @param options Build configuration options + * @throws BuildError if the build fails + */ + private async build( + client: ApiClient, + config: ConnectionConfig, + name: string, + options: Omit + ): Promise { + if (options.skipCache) { + this.force = true + } + + // Create template + options.onBuildLogs?.( + new LogEntry( + new Date(), + 'info', + `Requesting build for template: ${name}${options.tags && options.tags.length > 0 ? ` with tags ${options.tags.join(', ')}` : ''}` + ) + ) + + const { + templateID, + buildID, + tags: responseTags, + } = await requestBuild( + client, + { + name, + tags: options.tags, + cpuCount: options.cpuCount ?? 2, + memoryMB: options.memoryMB ?? 1024, + }, + config.getSignal(undefined, options.signal) + ) + + options.onBuildLogs?.( + new LogEntry( + new Date(), + 'info', + `Template created with ID: ${templateID}, Build ID: ${buildID}` + ) + ) + + const instructionsWithHashes = await this.instructionsWithHashes() + + // Upload files in parallel + const uploadPromises = instructionsWithHashes.map( + async (instruction, index) => { + if (instruction.type !== InstructionType.COPY) { + return + } + + const src = instruction.args.length > 0 ? instruction.args[0] : null + const filesHash = instruction.filesHash ?? null + if (src === null || filesHash === null) { + throw new Error('Source path and files hash are required') + } + + const forceUpload = instruction.forceUpload + let stackTrace = undefined + if (index + 1 >= 0 && index + 1 < this.stackTraces.length) { + stackTrace = this.stackTraces[index + 1] + } + + const { present, url } = await getFileUploadLink( + client, + { + templateID, + filesHash, + }, + stackTrace, + config.getSignal(undefined, options.signal) + ) + + if ( + (forceUpload && url != null) || + (present === false && url != null) + ) { + await uploadFile( + { + fileName: src, + fileContextPath: this.fileContextPath.toString(), + url, + ignorePatterns: [ + ...this.fileIgnorePatterns, + ...readDockerignore(this.fileContextPath.toString()), + ], + resolveSymlinks: instruction.resolveSymlinks ?? RESOLVE_SYMLINKS, + gzip: instruction.gzip ?? GZIP, + }, + stackTrace, + // Forward `requestTimeoutMs` only when the caller set it — we + // never want to slap the 60s default on a multi-hundred-MB S3 + // upload, but a user-set per-build timeout should govern the + // whole operation, including uploads. + { + signal: options.signal, + requestTimeoutMs: options.requestTimeoutMs, + } + ) + options.onBuildLogs?.( + new LogEntry(new Date(), 'info', `Uploaded '${src}'`) + ) + } else { + options.onBuildLogs?.( + new LogEntry( + new Date(), + 'info', + `Skipping upload of '${src}', already cached` + ) + ) + } + } + ) + + await Promise.all(uploadPromises) + + options.onBuildLogs?.( + new LogEntry(new Date(), 'info', 'All file uploads completed') + ) + + // Start build + options.onBuildLogs?.( + new LogEntry(new Date(), 'info', 'Starting building...') + ) + + await triggerBuild( + client, + { + templateID, + buildID, + template: this.serialize(instructionsWithHashes), + }, + config.getSignal(undefined, options.signal) + ) + + return { + alias: name, + name: name, + tags: responseTags, + templateId: templateID, + buildId: buildID, + } + } + + /** + * Add file hashes to COPY instructions for cache invalidation. + * + * @returns Copy of instructions array with filesHash added to COPY instructions + */ + private async instructionsWithHashes(): Promise { + return Promise.all( + this.instructions.map(async (instruction, index) => { + if (instruction.type !== InstructionType.COPY) { + return instruction + } + + const src = instruction.args.length > 0 ? instruction.args[0] : null + const dest = instruction.args.length > 1 ? instruction.args[1] : null + if (src === null || dest === null) { + throw new Error('Source path and destination path are required') + } + + let stackTrace = undefined + if (index + 1 >= 0 && index + 1 < this.stackTraces.length) { + stackTrace = this.stackTraces[index + 1] + } + + return { + ...instruction, + filesHash: await calculateFilesHash( + src, + dest, + this.fileContextPath.toString(), + [ + ...this.fileIgnorePatterns, + ...(runtime === 'browser' + ? [] + : readDockerignore(this.fileContextPath.toString())), + ], + instruction.resolveSymlinks ?? RESOLVE_SYMLINKS, + stackTrace + ), + } + }) + ) + } + + /** + * Serialize the template to the API request format. + * + * @param steps Array of build instructions with file hashes + * @returns Template data formatted for the API + */ + private serialize(steps: Instruction[]): TriggerBuildTemplate { + const templateData: TriggerBuildTemplate = { + startCmd: this.startCmd, + readyCmd: this.readyCmd, + steps, + force: this.force, + } + + if (this.baseImage !== undefined) { + templateData.fromImage = this.baseImage + } + + if (this.baseTemplate !== undefined) { + templateData.fromTemplate = this.baseTemplate + } + + if (this.registryConfig !== undefined) { + templateData.fromImageRegistry = this.registryConfig + } + + return templateData + } +} + +/** + * Create a new E2B template builder instance. + * + * @param options Optional configuration for the template builder + * @returns A new template builder instance + * + * @example + * ```ts + * import { Template } from 'e2b' + * + * const template = Template() + * .fromPythonImage('3') + * .copy('requirements.txt', '/app/') + * .pipInstall() + * + * await Template.build(template, 'my-python-app:v1.0') + * ``` + */ +export function Template(options?: TemplateOptions): TemplateFromImage { + return new TemplateBase(options) +} + +Template.build = TemplateBase.build +Template.buildInBackground = TemplateBase.buildInBackground +Template.getBuildStatus = TemplateBase.getBuildStatus +Template.exists = TemplateBase.exists +Template.aliasExists = TemplateBase.aliasExists +Template.assignTags = TemplateBase.assignTags +Template.removeTags = TemplateBase.removeTags +Template.getTags = TemplateBase.getTags +Template.toJSON = TemplateBase.toJSON +Template.toDockerfile = TemplateBase.toDockerfile + +export type { + BuildInfo, + BuildOptions, + BuildStatusReason, + CopyItem, + GetBuildStatusOptions, + McpServerName, + TemplateBuilder, + TemplateBuildStatus, + TemplateBuildStatusResponse, + TemplateClass, + TemplateTag, + TemplateTagInfo, +} from './types' diff --git a/packages/js-sdk/src/template/logger.ts b/packages/js-sdk/src/template/logger.ts new file mode 100644 index 0000000..d3f30af --- /dev/null +++ b/packages/js-sdk/src/template/logger.ts @@ -0,0 +1,216 @@ +import chalk from 'chalk' +import { stripAnsi } from '../utils' + +/** + * Log entry severity levels. + */ +export type LogEntryLevel = 'debug' | 'info' | 'warn' | 'error' + +/** + * Represents a single log entry from the template build process. + */ +export class LogEntry { + public readonly timestamp: Date + public readonly level: LogEntryLevel + public readonly message: string + + constructor(timestamp: Date, level: LogEntryLevel, message: string) { + this.timestamp = timestamp + this.level = level + // Strip ANSI escape codes on construction so messages are consistent + // everywhere (matches the Python SDK behavior) + this.message = stripAnsi(message) + } + + toString() { + return `[${this.timestamp.toISOString()}] [${this.level}] ${this.message}` + } +} + +/** + * Special log entry indicating the start of a build process. + */ +export class LogEntryStart extends LogEntry { + constructor(timestamp: Date, message: string) { + super(timestamp, 'debug', message) + } +} + +/** + * Special log entry indicating the end of a build process. + */ +export class LogEntryEnd extends LogEntry { + constructor(timestamp: Date, message: string) { + super(timestamp, 'debug', message) + } +} + +/** + * Interval in milliseconds for updating the build timer display. + * @internal + */ +const TIMER_UPDATE_INTERVAL_MS = 150 + +/** + * Default minimum log level to display. + * @internal + */ +const DEFAULT_LEVEL: LogEntryLevel = 'info' + +/** + * Colored labels for each log level. + * @internal + */ +const levels: Record = { + error: chalk.red('ERROR'), + warn: chalk.hex('#FF4400')('WARN '), + info: chalk.hex('#FF8800')('INFO '), + debug: chalk.gray('DEBUG'), +} + +/** + * Numeric ordering of log levels for comparison (lower = less severe). + * @internal + */ +const level_order: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +} + +interface DefaultBuildLoggerState { + startTime: number + animationFrame: number + timerInterval: NodeJS.Timeout | undefined +} + +class DefaultBuildLogger { + private minLevel: LogEntryLevel + private state: DefaultBuildLoggerState + + constructor(minLevel?: LogEntryLevel) { + this.minLevel = minLevel ?? DEFAULT_LEVEL + this.state = this.getInitialState() + } + + logger(logEntry: LogEntry) { + if (logEntry instanceof LogEntryStart) { + this.startTimer() + return + } + + if (logEntry instanceof LogEntryEnd) { + clearInterval(this.state.timerInterval) + return + } + + // Filter by minimum level + if (level_order[logEntry.level] < level_order[this.minLevel]) { + return + } + + const formattedLine = this.formatLogLine(logEntry) + process.stdout.write(`${formattedLine}\n`) + + // Redraw the timer line + this.updateTimer() + } + + private getInitialState( + timerInterval?: NodeJS.Timeout + ): DefaultBuildLoggerState { + return { + startTime: Date.now(), + animationFrame: 0, + timerInterval: timerInterval, + } + } + + private formatTimerLine() { + const elapsedSeconds = ((Date.now() - this.state.startTime) / 1000).toFixed( + 1 + ) + return `${elapsedSeconds}s` + } + + private animateStatus() { + const frames = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'] + const idx = this.state.animationFrame % frames.length + return `${frames[idx]}` + } + + private formatLogLine(line: LogEntry) { + const timer = this.formatTimerLine().padEnd(5) + + const timestamp = chalk.dim( + line.timestamp.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + ) + + const level = levels[line.level] || levels[DEFAULT_LEVEL] + + const msg = line.message + + return `${timer} | ${timestamp} ${level} ${msg}` + } + + private startTimer() { + if (!process.stdout.isTTY) { + return + } + + // Start the timer interval + const timerInterval = setInterval( + this.updateTimer.bind(this), + TIMER_UPDATE_INTERVAL_MS + ) + + this.state = this.getInitialState(timerInterval) + + // Initial timer display + this.updateTimer() + } + + private updateTimer() { + if (!process.stdout.isTTY) { + return + } + + this.state.animationFrame++ + const jumpingSquares = this.animateStatus() + process.stdout.write( + `${jumpingSquares} Building ${this.formatTimerLine()}\r` + ) + } +} + +/** + * Create a default build logger with animated timer display. + * + * @param options Logger configuration options + * @param options.minLevel Minimum log level to display (default: 'info') + * @returns Logger function that accepts LogEntry instances + * + * @example + * ```ts + * import { Template, defaultBuildLogger } from 'e2b' + * + * const template = Template().fromPythonImage() + * + * await Template.build(template, { + * alias: 'my-template', + * onBuildLogs: defaultBuildLogger({ minLevel: 'debug' }) + * }) + * ``` + */ +export function defaultBuildLogger(options?: { + minLevel?: LogEntryLevel +}): (logEntry: LogEntry) => void { + const buildLogger = new DefaultBuildLogger(options?.minLevel) + + return buildLogger.logger.bind(buildLogger) +} diff --git a/packages/js-sdk/src/template/readycmd.ts b/packages/js-sdk/src/template/readycmd.ts new file mode 100644 index 0000000..d4b6729 --- /dev/null +++ b/packages/js-sdk/src/template/readycmd.ts @@ -0,0 +1,127 @@ +import { shellQuote } from '../utils' + +/** + * Class for ready check commands. + */ +export class ReadyCmd { + private cmd: string + + constructor(cmd: string) { + this.cmd = cmd + } + + getCmd(): string { + return this.cmd + } +} + +/** + * Wait for a port to be listening. + * Uses `ss` command to check if a port is open and listening. + * + * @param port Port number to wait for + * @returns ReadyCmd that checks for the port + * + * @example + * ```ts + * import { Template, waitForPort } from 'e2b' + * + * const template = Template() + * .fromPythonImage() + * .setStartCmd('python -m http.server 8000', waitForPort(8000)) + * ``` + */ +export function waitForPort(port: number): ReadyCmd { + // Match the exact listening port via ss's source-port filter (so e.g. port + // 80 doesn't match 8080). ss exits 0 regardless of matches, so test for + // non-empty output to signal readiness. + const cmd = `[ -n "$(ss -Htuln sport = :${port})" ]` + return new ReadyCmd(cmd) +} + +/** + * Wait for a URL to return a specific HTTP status code. + * Uses `curl` to make HTTP requests and check the response status. + * + * @param url URL to check (e.g., 'http://localhost:3000/health') + * @param statusCode Expected HTTP status code (default: 200) + * @returns ReadyCmd that checks the URL + * + * @example + * ```ts + * import { Template, waitForURL } from 'e2b' + * + * const template = Template() + * .fromNodeImage() + * .setStartCmd('npm start', waitForURL('http://localhost:3000/health')) + * ``` + */ +export function waitForURL(url: string, statusCode: number = 200): ReadyCmd { + const cmd = `curl -s -o /dev/null -w "%{http_code}" ${shellQuote(url)} | grep -q "${statusCode}"` + return new ReadyCmd(cmd) +} + +/** + * Wait for a process with a specific name to be running. + * Uses `pgrep` to check if a process exists. + * + * @param processName Name of the process to wait for + * @returns ReadyCmd that checks for the process + * + * @example + * ```ts + * import { Template, waitForProcess } from 'e2b' + * + * const template = Template() + * .fromBaseImage() + * .setStartCmd('./my-daemon', waitForProcess('my-daemon')) + * ``` + */ +export function waitForProcess(processName: string): ReadyCmd { + const cmd = `pgrep ${shellQuote(processName)} > /dev/null` + return new ReadyCmd(cmd) +} + +/** + * Wait for a file to exist. + * Uses shell test command to check file existence. + * + * @param filename Path to the file to wait for + * @returns ReadyCmd that checks for the file + * + * @example + * ```ts + * import { Template, waitForFile } from 'e2b' + * + * const template = Template() + * .fromBaseImage() + * .setStartCmd('./init.sh', waitForFile('/tmp/ready')) + * ``` + */ +export function waitForFile(filename: string): ReadyCmd { + const cmd = `[ -f ${shellQuote(filename)} ]` + return new ReadyCmd(cmd) +} + +/** + * Wait for a specified timeout before considering the sandbox ready. + * Uses `sleep` command to wait for a fixed duration. + * + * @param timeout Time to wait in milliseconds (minimum: 1000ms / 1 second) + * @returns ReadyCmd that waits for the specified duration + * + * @example + * ```ts + * import { Template, waitForTimeout } from 'e2b' + * + * const template = Template() + * .fromNodeImage() + * .setStartCmd('npm start', waitForTimeout(5000)) // Wait 5 seconds + * ``` + */ +export function waitForTimeout(timeout: number): ReadyCmd { + // convert to seconds, but ensure minimum of 1 second + const seconds = Math.max(1, Math.floor(timeout / 1000)) + const cmd = `sleep ${seconds}` + return new ReadyCmd(cmd) +} diff --git a/packages/js-sdk/src/template/types.ts b/packages/js-sdk/src/template/types.ts new file mode 100644 index 0000000..ca3594c --- /dev/null +++ b/packages/js-sdk/src/template/types.ts @@ -0,0 +1,829 @@ +import { ReadyCmd } from './readycmd' +import type { PathLike } from 'node:fs' +import type { LogEntry } from './logger' +import type { McpServer } from '../sandbox/mcp' +import { ConnectionOpts } from '../connectionConfig' + +/** + * Options for creating a new template. + */ +export type TemplateOptions = { + /** + * Path to the directory containing files to be copied into the template. + * @default Current directory from template location + */ + fileContextPath?: PathLike + /** + * Array of glob patterns to ignore when copying files. + */ + fileIgnorePatterns?: string[] +} + +/** + * Basic options for building a template. + */ +export type BasicBuildOptions = { + /** + * Alias name for the template. + * @deprecated Use the `name` parameter of `Template.build()` instead. + */ + alias: string + /** + * Tags to assign to the template build. + */ + tags?: string[] + /** + * Number of CPUs allocated to the sandbox. + * @default 2 + */ + cpuCount?: number + /** + * Amount of memory in MB allocated to the sandbox. + * @default 1024 + */ + memoryMB?: number + /** + * If true, skips cache and forces a complete rebuild. + * @default false + */ + skipCache?: boolean + /** + * Callback function to receive build logs during the build process. + */ + onBuildLogs?: (logEntry: LogEntry) => void +} + +/** + * Options for building a template with authentication. + */ +export type BuildOptions = ConnectionOpts & BasicBuildOptions + +/** + * Information about a built template. + */ +export type BuildInfo = { + /** + * First alias from the build (for backward compatibility). + * @deprecated Use `name` instead. + */ + alias: string + /** + * Name of the template. + */ + name: string + /** + * Tags assigned to this build. + */ + tags: string[] + /** + * Template identifier. + */ + templateId: string + /** + * Build identifier. + */ + buildId: string +} + +/** + * Options for getting build status. + */ +export type GetBuildStatusOptions = ConnectionOpts & { logsOffset?: number } + +/** + * Status of a template build. + */ +export type TemplateBuildStatus = 'building' | 'waiting' | 'ready' | 'error' + +/** + * Reason for the current build status (typically for errors). + */ +export type BuildStatusReason = { + /** + * Message with the status reason. + */ + message: string + /** + * Step that failed. + */ + step?: string + /** + * Log entries related to the status reason. + */ + logEntries: LogEntry[] +} + +/** + * Response from getting build status. + */ +export type TemplateBuildStatusResponse = { + /** + * Build identifier. + */ + buildID: string + /** + * Template identifier. + */ + templateID: string + /** + * Current status of the build. + */ + status: TemplateBuildStatus + /** + * Build log entries. + */ + logEntries: LogEntry[] + /** + * Build logs (raw strings). + * @deprecated Use `logEntries` instead. + */ + logs: string[] + /** + * Reason for the current status (typically for errors). + */ + reason?: BuildStatusReason +} + +/** + * Information about assigned template tags. + */ +export type TemplateTagInfo = { + /** + * Build identifier associated with this tag. + */ + buildId: string + /** + * Assigned tags of the template. + */ + tags: string[] +} + +/** + * Detailed information about a single template tag. + */ +export type TemplateTag = { + /** + * Name of the tag. + */ + tag: string + /** + * Build identifier associated with this tag. + */ + buildId: string + /** + * When this tag was assigned. + */ + createdAt: Date +} + +/** + * Types of instructions that can be used in a template. + */ +export enum InstructionType { + COPY = 'COPY', + ENV = 'ENV', + RUN = 'RUN', + WORKDIR = 'WORKDIR', + USER = 'USER', +} + +/** + * Represents a single instruction in the template build process. + */ +export type Instruction = { + type: InstructionType + args: string[] + force: boolean + forceUpload?: true + filesHash?: string + resolveSymlinks?: boolean + gzip?: boolean +} + +/** + * Configuration for a single file/directory copy operation. + */ +export type CopyItem = { + src: PathLike | PathLike[] + dest: PathLike + forceUpload?: true + user?: string + mode?: number + resolveSymlinks?: boolean + gzip?: boolean +} + +/** + * MCP server names that can be installed. + */ +export type McpServerName = keyof McpServer + +/** + * Initial state of a template builder. + * Use one of these methods to specify the base image or template to start from. + */ +export interface TemplateFromImage { + /** + * Start from a Debian-based Docker image. + * @param variant Debian variant (default: 'stable') + * + * @example + * ```ts + * Template().fromDebianImage('bookworm') + * ``` + */ + fromDebianImage(variant?: string): TemplateBuilder + + /** + * Start from an Ubuntu-based Docker image. + * @param variant Ubuntu variant (default: 'latest') + * + * @example + * ```ts + * Template().fromUbuntuImage('24.04') + * ``` + */ + fromUbuntuImage(variant?: string): TemplateBuilder + + /** + * Start from a Python-based Docker image. + * @param version Python version (default: '3') + * + * @example + * ```ts + * Template().fromPythonImage('3') + * ``` + */ + fromPythonImage(version?: string): TemplateBuilder + + /** + * Start from a Node.js-based Docker image. + * @param variant Node.js variant (default: 'lts') + * + * @example + * ```ts + * Template().fromNodeImage('24') + * ``` + */ + fromNodeImage(variant?: string): TemplateBuilder + + /** + * Start from a Bun-based Docker image. + * @param variant Bun variant (default: 'latest') + * + * @example + * ```ts + * Template().fromBunImage('1.3') + * ``` + */ + fromBunImage(variant?: string): TemplateBuilder + + /** + * Start from E2B's default base image (e2bdev/base:latest). + * + * @example + * ```ts + * Template().fromBaseImage() + * ``` + */ + fromBaseImage(): TemplateBuilder + + /** + * Start from a custom Docker image. + * @param baseImage Docker image name + * @param credentials Optional credentials for private registries + * + * @example + * ```ts + * Template().fromImage('python:3') + * + * // With credentials (optional) + * Template().fromImage('myregistry.com/myimage:latest', { + * username: 'user', + * password: 'pass' + * }) + * ``` + */ + fromImage( + baseImage: string, + credentials?: { username: string; password: string } + ): TemplateBuilder + + /** + * Start from an existing E2B template. + * @param template E2B template ID or alias + * + * @example + * ```ts + * Template().fromTemplate('my-base-template') + * ``` + */ + fromTemplate(template: string): TemplateBuilder + + /** + * Parse a Dockerfile and convert it to Template SDK format. + * @param dockerfileContentOrPath Dockerfile content or path + * + * @example + * ```ts + * Template().fromDockerfile('Dockerfile') + * Template().fromDockerfile('FROM python:3\nRUN pip install numpy') + * ``` + */ + fromDockerfile(dockerfileContentOrPath: string): TemplateBuilder + + /** + * Start from a Docker image in AWS ECR. + * @param image Full ECR image path + * @param credentials AWS credentials + * + * @example + * ```ts + * Template().fromAWSRegistry( + * '123456789.dkr.ecr.us-west-2.amazonaws.com/myimage:latest', + * { + * accessKeyId: 'AKIA...', + * secretAccessKey: '...', + * region: 'us-west-2' + * } + * ) + * ``` + */ + fromAWSRegistry( + image: string, + credentials: { + accessKeyId: string + secretAccessKey: string + region: string + } + ): TemplateBuilder + + /** + * Start from a Docker image in Google Container Registry. + * @param image Full GCR/GAR image path + * @param credentials GCP service account credentials + * + * @example + * ```ts + * Template().fromGCPRegistry( + * 'gcr.io/myproject/myimage:latest', + * { serviceAccountJSON: 'path/to/service-account.json' } + * ) + * ``` + */ + fromGCPRegistry( + image: string, + credentials: { + serviceAccountJSON: object | string + } + ): TemplateBuilder + + /** + * Skip cache for all subsequent build instructions from this point. + * + * @example + * ```ts + * Template().skipCache().fromPythonImage('3') + * ``` + */ + skipCache(): this +} + +/** + * Main builder state for constructing templates. + * Provides methods for customizing the template environment. + */ +export interface TemplateBuilder { + /** + * Copy files or directories into the template. + * @param src Source path(s) + * @param dest Destination path + * @param options Copy options + * + * @example + * ```ts + * template.copy('requirements.txt', '/home/user/') + * template.copy(['app.ts', 'config.ts'], '/app/', { mode: 0o755 }) + * ``` + */ + copy( + src: PathLike | PathLike[], + dest: PathLike, + options?: { + forceUpload?: true + user?: string + mode?: number + resolveSymlinks?: boolean + gzip?: boolean + } + ): TemplateBuilder + + /** + * Copy multiple items with individual options. + * @param items Array of copy items + * + * @example + * ```ts + * template.copyItems([ + * { src: 'app.ts', dest: '/app/' }, + * { src: 'config.ts', dest: '/app/', mode: 0o644 } + * ]) + * ``` + */ + copyItems(items: CopyItem[]): TemplateBuilder + + /** + * Remove files or directories. + * @param path Path(s) to remove + * @param options Remove options + * + * @example + * ```ts + * template.remove('/tmp/cache', { recursive: true, force: true }) + * template.remove('/tmp/cache', { recursive: true, force: true, user: 'root' }) + * ``` + */ + remove( + path: PathLike | PathLike[], + options?: { force?: boolean; recursive?: boolean; user?: string } + ): TemplateBuilder + + /** + * Rename or move a file or directory. + * @param src Source path + * @param dest Destination path + * @param options Rename options + * + * @example + * ```ts + * template.rename('/tmp/old.txt', '/tmp/new.txt') + * template.rename('/tmp/old.txt', '/tmp/new.txt', { user: 'root' }) + * ``` + */ + rename( + src: PathLike, + dest: PathLike, + options?: { force?: boolean; user?: string } + ): TemplateBuilder + + /** + * Create directories. + * @param path Directory path(s) + * @param options Directory options + * + * @example + * ```ts + * template.makeDir('/app/data', { mode: 0o755 }) + * template.makeDir(['/app/logs', '/app/cache']) + * template.makeDir('/app/data', { mode: 0o755, user: 'root' }) + * ``` + */ + makeDir( + path: PathLike | PathLike[], + options?: { mode?: number; user?: string } + ): TemplateBuilder + + /** + * Create a symbolic link. + * @param src Source path (target) + * @param dest Destination path (symlink location) + * @param options Symlink options + * + * @example + * ```ts + * template.makeSymlink('/usr/bin/python3', '/usr/bin/python') + * template.makeSymlink('/usr/bin/python3', '/usr/bin/python', { user: 'root' }) + * template.makeSymlink('/usr/bin/python3', '/usr/bin/python', { force: true }) + * ``` + */ + makeSymlink( + src: PathLike, + dest: PathLike, + options?: { user?: string; force?: boolean } + ): TemplateBuilder + + /** + * Run a shell command. + * @param command Command string + * @param options Command options + * + * @example + * ```ts + * template.runCmd('apt-get update') + * template.runCmd(['pip install numpy', 'pip install pandas']) + * template.runCmd('apt-get install vim', { user: 'root' }) + * ``` + */ + runCmd(command: string, options?: { user?: string }): TemplateBuilder + + /** + * Run multiple shell commands. + * @param commands Array of command strings + * @param options Command options + */ + runCmd(commands: string[], options?: { user?: string }): TemplateBuilder + + /** + * Run command(s). + * @param commandOrCommands Command or commands + * @param options Command options + */ + runCmd( + commandOrCommands: string | string[], + options?: { user?: string } + ): TemplateBuilder + + /** + * Set the working directory. + * @param workdir Working directory path + * + * @example + * ```ts + * template.setWorkdir('/app') + * ``` + */ + setWorkdir(workdir: PathLike): TemplateBuilder + + /** + * Set the user for subsequent commands. + * @param user Username + * + * @example + * ```ts + * template.setUser('root') + * ``` + */ + setUser(user: string): TemplateBuilder + + /** + * Install Python packages using pip. + * @param packages Package name(s) or undefined for current directory + * @param options Install options + * @param options.g Install globally as root (default: true). Set to false for user-only installation with --user flag + * + * @example + * ```ts + * template.pipInstall('numpy') // Installs globally (default) + * template.pipInstall(['pandas', 'scikit-learn']) + * template.pipInstall('numpy', { g: false }) // Install for user only + * template.pipInstall() // Installs from current directory + * ``` + */ + pipInstall( + packages?: string | string[], + options?: { g?: boolean } + ): TemplateBuilder + + /** + * Install Node.js packages using npm. + * @param packages Package name(s) or undefined for package.json + * @param options Install options + * + * @example + * ```ts + * template.npmInstall('express') + * template.npmInstall(['lodash', 'axios']) + * template.npmInstall('tsx', { g: true }) + * template.npmInstall('typescript', { dev: true }) + * template.npmInstall() // Installs from package.json + * ``` + */ + npmInstall( + packages?: string | string[], + options?: { g?: boolean; dev?: boolean } + ): TemplateBuilder + /** + * Install Bun packages using bun. + * @param packages Package name(s) or undefined for package.json + * @param options Install options + * + * @example + * ```ts + * template.bunInstall('express') + * template.bunInstall(['lodash', 'axios']) + * template.bunInstall('tsx', { g: true }) + * template.bunInstall('typescript', { dev: true }) + * template.bunInstall() // Installs from package.json + * ``` + */ + bunInstall( + packages?: string | string[], + options?: { g?: boolean; dev?: boolean } + ): TemplateBuilder + + /** + * Install Debian/Ubuntu packages using apt-get. + * @param packages Package name(s) + * + * @example + * ```ts + * template.aptInstall('vim') + * template.aptInstall(['git', 'curl', 'wget']) + * template.aptInstall(['vim'], { noInstallRecommends: true }) + * template.aptInstall(['vim'], { fixMissing: true }) + * ``` + */ + aptInstall( + packages: string | string[], + options?: { noInstallRecommends?: boolean; fixMissing?: boolean } + ): TemplateBuilder + + /** + * Install MCP servers using mcp-gateway. + * Note: Requires a base image with mcp-gateway pre-installed (e.g., mcp-gateway). + * @param servers MCP server name(s) + * + * @throws {Error} If the base template is not mcp-gateway + * @example + * ```ts + * template.addMcpServer('exa') + * template.addMcpServer(['brave', 'firecrawl', 'duckduckgo']) + * ``` + */ + addMcpServer(servers: McpServerName | McpServerName[]): TemplateBuilder + + /** + * Clone a Git repository. + * @param url Repository URL + * @param path Optional destination path + * @param options Clone options + * + * @example + * ```ts + * template.gitClone('https://github.com/user/repo.git', '/app/repo') + * template.gitClone('https://github.com/user/repo.git', undefined, { + * branch: 'main', + * depth: 1 + * }) + * template.gitClone('https://github.com/user/repo.git', '/app/repo', { + * user: 'root' + * }) + * ``` + */ + gitClone( + url: string, + path?: PathLike, + options?: { branch?: string; depth?: number; user?: string } + ): TemplateBuilder + + /** + * Set environment variables. + * Note: Environment variables defined here are available only during template build. + * @param envs Environment variables + * + * @example + * ```ts + * template.setEnvs({ NODE_ENV: 'production', PORT: '8080' }) + * ``` + */ + setEnvs(envs: Record): TemplateBuilder + + /** + * Skip cache for all subsequent build instructions from this point. + * + * @example + * ```ts + * template.skipCache().runCmd('apt-get update') + * ``` + */ + skipCache(): this + + /** + * Set the start command and ready check. + * @param startCommand Command to run on startup + * @param readyCommand Command to check readiness + * + * @example + * ```ts + * // Using a string command + * template.setStartCmd( + * 'node app.js', + * 'curl http://localhost:8000/health' + * ) + * + * // Using ReadyCmd helpers + * import { waitForPort, waitForURL } from 'e2b' + * + * template.setStartCmd( + * 'python -m http.server 8000', + * waitForPort(8000) + * ) + * + * template.setStartCmd( + * 'npm start', + * waitForURL('http://localhost:3000/health', 200) + * ) + * ``` + */ + setStartCmd( + startCommand: string, + readyCommand: string | ReadyCmd + ): TemplateFinal + + /** + * Set or update the ready check command. + * @param readyCommand Command to check readiness + * + * @example + * ```ts + * // Using a string command + * template.setReadyCmd('curl http://localhost:8000/health') + * + * // Using ReadyCmd helpers + * import { waitForPort, waitForFile, waitForProcess } from 'e2b' + * + * template.setReadyCmd(waitForPort(3000)) + * + * template.setReadyCmd(waitForFile('/tmp/ready')) + * + * template.setReadyCmd(waitForProcess('nginx')) + * ``` + */ + setReadyCmd(readyCommand: string | ReadyCmd): TemplateFinal + + /** + * Prebuild a devcontainer from the specified directory. + * @param devcontainerDirectory Path to the devcontainer directory + * + * @example + * ```ts + * template + * .gitClone('https://myrepo.com/project.git', '/my-devcontainer') + * .betaDevContainerPrebuild('/my-devcontainer') + * ``` + */ + betaDevContainerPrebuild(devcontainerDirectory: string): TemplateBuilder + + /** + * Start a devcontainer from the specified directory. + * @param devcontainerDirectory Path to the devcontainer directory + * + * @example + * ```ts + * template + * .gitClone('https://myrepo.com/project.git', '/my-devcontainer') + * .startDevcontainer('/my-devcontainer') + * + * // Prebuild and start + * template + * .gitClone('https://myrepo.com/project.git', '/my-devcontainer') + * .betaDevContainerPrebuild('/my-devcontainer') + * // Other instructions... + * .betaSetDevContainerStart('/my-devcontainer') + * ``` + */ + betaSetDevContainerStart(devcontainerDirectory: string): TemplateFinal +} + +/** + * Final state of a template after start/ready commands are set. + * The template can only be built in this state. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface TemplateFinal {} + +/** + * Configuration for a generic Docker registry with basic authentication. + */ +export type GenericDockerRegistry = { + /** Registry type identifier */ + type: 'registry' + /** Registry username */ + username: string + /** Registry password */ + password: string +} + +/** + * Configuration for AWS Elastic Container Registry (ECR). + */ +export type AWSRegistry = { + /** Registry type identifier */ + type: 'aws' + /** AWS access key ID */ + awsAccessKeyId: string + /** AWS secret access key */ + awsSecretAccessKey: string + /** AWS region */ + awsRegion: string +} + +/** + * Configuration for Google Container Registry (GCR) or Artifact Registry. + */ +export type GCPRegistry = { + /** Registry type identifier */ + type: 'gcp' + /** Service account JSON as string */ + serviceAccountJson: string +} + +/** + * Union type for all supported container registry configurations. + */ +export type RegistryConfig = GenericDockerRegistry | AWSRegistry | GCPRegistry + +/** + * Type representing a template in any state (builder or final). + */ +export type TemplateClass = TemplateBuilder | TemplateFinal diff --git a/packages/js-sdk/src/template/utils.ts b/packages/js-sdk/src/template/utils.ts new file mode 100644 index 0000000..933cdd1 --- /dev/null +++ b/packages/js-sdk/src/template/utils.ts @@ -0,0 +1,474 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { dynamicImport, dynamicRequire } from '../utils' +import { TemplateError } from '../errors' +import { BASE_STEP_NAME, FINALIZE_STEP_NAME } from './consts' +import type { Readable } from 'node:stream' +import type { Path } from 'glob' +import type { BuildOptions } from './types' + +/** + * Validate that a source path for copy operations is a relative path that stays + * within the context directory. This prevents path traversal attacks and ensures + * files are copied from within the expected directory. + * + * @param src The source path to validate + * @param stackTrace Optional stack trace for error reporting + * @throws TemplateError if the path is absolute or escapes the context directory + * + * Invalid paths: + * - Absolute paths: /absolute/path, C:\Windows\path + * - Parent directory escapes: ../foo, foo/../../bar, ./foo/../../../bar + * + * Valid paths: + * - Simple relative: foo, foo/bar + * - Current directory prefix: ./foo, ./foo/bar + * - Internal parent refs that don't escape: foo/../bar (stays within context) + */ +export function validateRelativePath( + src: string, + stackTrace: string | undefined +): void { + // Check for absolute paths using Node's cross-platform implementation + if (path.isAbsolute(src)) { + const error = new TemplateError( + `Invalid source path "${src}": absolute paths are not allowed. Use a relative path within the context directory.`, + stackTrace + ) + throw error + } + + // Normalize the path and check if it escapes the context directory + const normalized = path.normalize(src) + + // After normalization, a path that escapes would be '..' or start with '../' + // We check for '..' followed by path separator to avoid false positives on filenames like '..myconfig' + // Examples: + // - '../foo' -> '../foo' (escapes) + // - 'foo/../../bar' -> '../bar' (escapes) + // - './foo/../../../bar' -> '../../bar' (escapes) + // - 'foo/../bar' -> 'bar' (doesn't escape) + // - './foo/bar' -> 'foo/bar' (doesn't escape) + // - '..myconfig' -> '..myconfig' (valid filename, doesn't escape) + const escapes = normalized === '..' || normalized.startsWith('..' + path.sep) + + if (escapes) { + const error = new TemplateError( + `Invalid source path "${src}": path escapes the context directory. The path must stay within the context directory.`, + stackTrace + ) + throw error + } +} + +/** + * Normalize build arguments from different overload signatures. + * Handles string name or legacy options object with alias. + * + * @param nameOrOptions Name or legacy options with alias + * @param options Optional build options (when first arg is name) + * @returns Object with normalized name, tags, and build options + * @throws TemplateError if no template name is provided + */ +export function normalizeBuildArguments( + nameOrOptions: string | BuildOptions, + options?: Omit +): { + name: string + buildOptions: Omit +} { + let name: string + let buildOptions: Omit + + if (typeof nameOrOptions === 'string') { + name = nameOrOptions + buildOptions = options ?? {} + } else { + // Legacy: options object with alias + const { alias, ...restOpts } = nameOrOptions + name = alias + buildOptions = restOpts + } + + if (!name || name.length === 0) { + throw new TemplateError('Name must be provided') + } + + return { name, buildOptions } +} + +/** + * Read and parse a .dockerignore file. + * + * @param contextPath Directory path containing the .dockerignore file + * @returns Array of ignore patterns (empty lines and comments are filtered out) + */ +export function readDockerignore(contextPath: string): string[] { + const dockerignorePath = path.join(contextPath, '.dockerignore') + if (!fs.existsSync(dockerignorePath)) { + return [] + } + + const content = fs.readFileSync(dockerignorePath, 'utf-8') + return content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) +} + +/** + * Normalize path separators to forward slashes for glob patterns (glob expects / even on Windows) + * @param path - The path to normalize + * @returns The normalized path + */ +function normalizePath(path: string): string { + return path.replace(/\\/g, '/') +} + +/** + * Get all files for a given path and ignore patterns. + * + * @param src Path to the source directory + * @param contextPath Base directory for resolving relative paths + * @param ignorePatterns Ignore patterns + * @returns Array of files + */ +export async function getAllFilesInPath( + src: string, + contextPath: string, + ignorePatterns: string[], + includeDirectories: boolean = true +) { + const { glob } = await dynamicImport('glob') + const files = new Map() + + const globFiles = await glob(src, { + ignore: ignorePatterns, + withFileTypes: true, + dot: true, + // this is required so that the ignore pattern is relative to the file path + cwd: contextPath, + }) + + for (const file of globFiles) { + if (file.isDirectory()) { + // For directories, add the directory itself and all files inside it + if (includeDirectories) { + files.set(file.fullpath(), file) + } + const dirPattern = normalizePath( + // When the matched directory is '.', `file.relative()` can be an empty string. + // In that case, we want to match all files under the current directory instead of + // creating an absolute glob like '/**/*' which would traverse the entire filesystem. + path.join(file.relative() || '.', '**/*') + ) + const dirFiles = await glob(dirPattern, { + ignore: ignorePatterns, + withFileTypes: true, + dot: true, + cwd: contextPath, + }) + dirFiles.forEach((f) => files.set(f.fullpath(), f)) + } else { + // For files, just add the file + files.set(file.fullpath(), file) + } + } + + // Sort by full path for a deterministic order — the default sort() would + // stringify the Path objects to '[object Object]' and keep glob order, + // making the files hash dependent on filesystem traversal order. + return Array.from(files.values()).sort((a, b) => + a.fullpath() < b.fullpath() ? -1 : a.fullpath() > b.fullpath() ? 1 : 0 + ) +} + +/** + * Calculate a hash of files being copied to detect changes for cache invalidation. + * The hash includes file content, metadata (mode, size), and relative paths. + * Note: uid, gid, and mtime are excluded to ensure stable hashes across environments. + * + * @param src Source path pattern for files to copy + * @param dest Destination path where files will be copied + * @param contextPath Base directory for resolving relative paths + * @param ignorePatterns Glob patterns to ignore + * @param resolveSymlinks Whether to resolve symbolic links when hashing + * @param stackTrace Optional stack trace for error reporting + * @returns Hex string hash of all files + * @throws Error if no files match the source pattern + */ +export async function calculateFilesHash( + src: string, + dest: string, + contextPath: string, + ignorePatterns: string[], + resolveSymlinks: boolean, + stackTrace: string | undefined +): Promise { + const srcPath = path.join(contextPath, src) + const hash = crypto.createHash('sha256') + const content = `COPY ${src} ${dest}` + + hash.update(content) + + const files = await getAllFilesInPath(src, contextPath, ignorePatterns, true) + + if (files.length === 0) { + const error = new Error(`No files found in ${srcPath}`) + if (stackTrace) { + error.stack = stackTrace + } + throw error + } + + // Hash stats - only include stable metadata (mode, size) + // Exclude uid, gid, and mtime to ensure consistent hashes across environments + const hashStats = (stats: fs.Stats) => { + hash.update(stats.mode.toString()) + hash.update(stats.size.toString()) + } + + // Process files recursively + for (const file of files) { + // Add a relative path to hash calculation + const relativePath = file.relativePosix() + hash.update(relativePath) + + // Add stat information to hash calculation + if (file.isSymbolicLink()) { + // If the symlink is broken, it will return undefined, otherwise it will return a stats object of the target + const stats = fs.statSync(file.fullpath(), { throwIfNoEntry: false }) + const shouldFollow = + resolveSymlinks && (stats?.isFile() || stats?.isDirectory()) + + if (!shouldFollow) { + const stats = fs.lstatSync(file.fullpath()) + + hashStats(stats) + + const content = fs.readlinkSync(file.fullpath()) + hash.update(content) + + continue + } + } + + const stats = fs.statSync(file.fullpath()) + hashStats(stats) + + // Add file content to hash calculation + if (stats.isFile()) { + const content = fs.readFileSync(file.fullpath()) + hash.update(new Uint8Array(content)) + } + } + + return hash.digest('hex') +} + +/** + * Get the caller's stack trace frame at a specific depth. + * + * @param depth The depth of the stack trace to retrieve + * - Levels: caller (e.g., TemplateBase.fromImage) > original caller (e.g., user's template file) + * @returns The caller frame as a string, or undefined if not available + */ +export function getCallerFrame(depth: number): string | undefined { + const stackTrace = new Error().stack + if (!stackTrace) { + return + } + + const lines = stackTrace.split('\n').slice(1) // Skip the this function (getCallerFrame) + if (lines.length < depth + 1) { + return + } + + return lines.slice(depth).join('\n') +} + +// adopted from https://github.com/sindresorhus/callsites +export function callsites(depth: number): NodeJS.CallSite[] { + const _originalPrepareStackTrace = Error.prepareStackTrace + try { + let result: NodeJS.CallSite[] = [] + Error.prepareStackTrace = (_, callSites) => { + const callSitesWithoutCurrent = callSites.slice(depth) + result = callSitesWithoutCurrent + return callSitesWithoutCurrent + } + + // Accessing `.stack` triggers `Error.prepareStackTrace` for its side effect. + // oxlint-disable-next-line no-unused-expressions + new Error().stack + return result + } finally { + Error.prepareStackTrace = _originalPrepareStackTrace + } +} + +/** + * Get the directory of the caller at a specific stack depth. + * + * @param depth The depth of the stack trace + * @returns The caller's directory path, or undefined if not available + */ +export function getCallerDirectory(depth: number): string | undefined { + // +1 depth to skip this function (getCallerDirectory) + const callSites = callsites(depth + 1) + if (callSites.length === 0) { + return undefined + } + + let fileName = callSites[0].getFileName() + if (!fileName) { + return undefined + } + + // Handle file:// URLs returned by getFileName() in ESM modules + if (fileName.startsWith('file:')) { + // we use the dynamic import to avoid bundling node:url for browser compatibility + // getCallerDirectory method is not called in the browser + const { fileURLToPath } = + dynamicRequire('node:url') + fileName = fileURLToPath(fileName) + } + + return path.dirname(fileName) +} + +/** + * Convert a numeric file mode to a zero-padded octal string. + * + * @param mode File mode as a number (e.g., 493 for 0o755) + * @returns Zero-padded 4-digit octal string (e.g., "0755") + * + * @example + * ```ts + * padOctal(0o755) // Returns "0755" + * padOctal(0o644) // Returns "0644" + * ``` + */ +export function padOctal(mode: number): string { + return mode.toString(8).padStart(4, '0') +} + +/** + * Create a gzipped tar archive of files matching a pattern and return a + * readable stream over it. + * + * The archive is spooled to a temporary file on disk so it can be uploaded as + * a stream with a known `Content-Length` instead of being buffered in memory. + * The temporary file is removed automatically once the returned stream is + * closed — whether it was fully read, errored, or destroyed. This mirrors the + * Python SDK's `tar_file_stream`, where closing the temp-file handle deletes + * it (Node has no portable delete-on-close, so cleanup is tied to the stream's + * `close` event instead). + * + * @param fileName Glob pattern for files to include + * @param fileContextPath Base directory for resolving file paths + * @param ignorePatterns Ignore patterns to exclude from the archive + * @param resolveSymlinks Whether to follow symbolic links + * @param gzip Whether to gzip the archive + * @returns The readable stream and the archive size in bytes + */ +export async function tarFileStream( + fileName: string, + fileContextPath: string, + ignorePatterns: string[], + resolveSymlinks: boolean, + gzip: boolean +): Promise<{ stream: Readable; size: number }> { + const { create } = await dynamicImport('tar') + // Dynamically import so the browser bundle doesn't pull in node:fs. + const { createReadStream } = + await dynamicImport('node:fs') + + const allFiles = await getAllFilesInPath( + fileName, + fileContextPath, + ignorePatterns, + true + ) + + const filePaths = allFiles.map((file) => file.relativePosix()) + + const tmpDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'e2b-template-') + ) + const tarPath = path.join(tmpDir, 'context.tar.gz') + // Best-effort removal so it can never mask the archive-creation error or the + // upload result. A leaked temp dir is non-fatal — the OS reclaims it. + const removeTmpDir = () => + fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + + try { + await create( + { + gzip, + cwd: fileContextPath, + follow: resolveSymlinks, + noDirRecurse: true, + file: tarPath, + }, + filePaths + ) + + const { size } = await fs.promises.stat(tarPath) + + const stream = createReadStream(tarPath) + // Remove the spooled archive once the stream is done — `close` fires after + // the fd is released on every path (end, error, or destroy). + stream.once('close', removeTmpDir) + return { stream, size } + } catch (err) { + await removeTmpDir() + throw err + } +} + +/** + * Get the array index for a build step based on its name. + * + * Special steps: + * - BASE_STEP_NAME: Returns 0 (first step) + * - FINALIZE_STEP_NAME: Returns the last index + * - Numeric strings: Converted to number + * + * @param step Build step name or number as string + * @param stackTracesLength Total number of stack traces (used for FINALIZE_STEP_NAME) + * @returns Index for the build step + */ +export function getBuildStepIndex( + step: string, + stackTracesLength: number +): number { + if (step === BASE_STEP_NAME) { + return 0 + } + + if (step === FINALIZE_STEP_NAME) { + return stackTracesLength - 1 + } + + return Number(step) +} + +/** + * Read GCP service account JSON from a file or object. + * + * @param contextPath Base directory for resolving relative file paths + * @param pathOrContent Either a path to a JSON file or a service account object + * @returns Service account JSON as a string + */ +export function readGCPServiceAccountJSON( + contextPath: string, + pathOrContent: string | object +): string { + if (typeof pathOrContent === 'string') { + return fs.readFileSync(path.join(contextPath, pathOrContent), 'utf-8') + } + return JSON.stringify(pathOrContent) +} diff --git a/packages/js-sdk/src/undici.ts b/packages/js-sdk/src/undici.ts new file mode 100644 index 0000000..e3566c0 --- /dev/null +++ b/packages/js-sdk/src/undici.ts @@ -0,0 +1,64 @@ +export type UndiciRequestInit = RequestInit & { + dispatcher?: unknown + duplex?: 'half' +} + +export type UndiciModule = { + Agent: new (options: { allowH2: true; connections?: number }) => unknown + ProxyAgent: new (options: { + uri: string + allowH2: true + connections?: number + }) => unknown + fetch: unknown +} + +export async function loadUndici(): Promise { + try { + // Keep this import opaque to bundlers. It must resolve as a package name + // from the runtime environment, not as a path relative to this file. + // eslint-disable-next-line no-new-func + const importModule = new Function( + 'moduleName', + 'return import(moduleName)' + ) as (moduleName: string) => Promise + + return await importModule('undici') + } catch { + return undefined + } +} + +export function toUndiciRequestInput( + input: RequestInfo | URL, + init?: RequestInit +): { input: RequestInfo | URL; init?: RequestInit & { duplex?: 'half' } } { + if (!(input instanceof Request)) { + return { input, init } + } + + const requestInit: RequestInit & { duplex?: 'half' } = { + body: input.body, + cache: input.cache, + credentials: input.credentials, + headers: input.headers, + integrity: input.integrity, + keepalive: input.keepalive, + method: input.method, + mode: input.mode, + redirect: input.redirect, + referrer: input.referrer, + referrerPolicy: input.referrerPolicy, + signal: input.signal, + ...init, + } + + if (requestInit.body) { + requestInit.duplex = 'half' + } + + return { + input: input.url, + init: requestInit, + } +} diff --git a/packages/js-sdk/src/utils.ts b/packages/js-sdk/src/utils.ts new file mode 100644 index 0000000..245b4e4 --- /dev/null +++ b/packages/js-sdk/src/utils.ts @@ -0,0 +1,174 @@ +import platform from 'platform' + +declare let window: any + +type Runtime = + | 'node' + | 'browser' + | 'deno' + | 'bun' + | 'vercel-edge' + | 'cloudflare-worker' + | 'unknown' + +function getRuntime(): { runtime: Runtime; version: string } { + // @ts-ignore + if ((globalThis as any).Bun) { + // @ts-ignore + return { runtime: 'bun', version: globalThis.Bun.version } + } + + // @ts-ignore + if ((globalThis as any).Deno) { + // @ts-ignore + return { runtime: 'deno', version: globalThis.Deno.version.deno } + } + + if ((globalThis as any).process?.release?.name === 'node') { + return { runtime: 'node', version: platform.version || 'unknown' } + } + + // @ts-ignore + if (typeof EdgeRuntime === 'string') { + return { runtime: 'vercel-edge', version: 'unknown' } + } + + if ((globalThis as any).navigator?.userAgent === 'Cloudflare-Workers') { + return { runtime: 'cloudflare-worker', version: 'unknown' } + } + + if (typeof window !== 'undefined') { + return { runtime: 'browser', version: platform.version || 'unknown' } + } + + return { runtime: 'unknown', version: 'unknown' } +} + +export const { runtime, version: runtimeVersion } = getRuntime() + +export async function sha256(data: string): Promise { + // Use WebCrypto API if available + if (typeof crypto !== 'undefined') { + const encoder = new TextEncoder() + const dataBuffer = encoder.encode(data) + const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer) + const hashArray = new Uint8Array(hashBuffer) + return btoa(String.fromCharCode(...hashArray)) + } + + // Use Node.js crypto if WebCrypto is not available + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { createHash } = require('node:crypto') + const hash = createHash('sha256').update(data, 'utf8').digest() + return hash.toString('base64') +} + +export function timeoutToSeconds(timeout: number): number { + return Math.ceil(timeout / 1000) +} + +export function dynamicRequire(module: string): T { + if (runtime === 'browser') { + throw new Error('Browser runtime is not supported for require') + } + + return require(module) +} + +export async function dynamicImport(module: string): Promise { + if (runtime === 'browser') { + throw new Error('Browser runtime is not supported for dynamic import') + } + + // @ts-ignore + return await import(module) +} + +// Source: https://github.com/chalk/ansi-regex/blob/main/index.js +function ansiRegex({ onlyFirst = false } = {}) { + // Valid string terminator sequences are BEL, ESC\, and 0x9c + const ST = '(?:\\u0007|\\u001B\\u005C|\\u009C)' + // OSC sequences only: ESC ] ... ST (non-greedy until the first ST) + const osc = `(?:\\u001B\\][\\s\\S]*?${ST})` + // CSI and related: ESC/C1, optional intermediates, optional params (supports ; and :) then final byte + const csi = + '[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]' + + const pattern = `${osc}|${csi}` + + return new RegExp(pattern, onlyFirst ? undefined : 'g') +} + +export function stripAnsi(text: string): string { + return text.replace(ansiRegex(), '') +} + +/** + * Convert data to a Blob, avoiding unnecessary conversions when possible. + */ +export function toBlob( + data: string | ArrayBuffer | Blob | ReadableStream +): Blob | Promise { + // Already a Blob - use directly + if (data instanceof Blob) { + return data + } + // String or ArrayBuffer - create Blob + if (typeof data === 'string' || data instanceof ArrayBuffer) { + return new Blob([data]) + } + // ReadableStream - must consume to get Blob + return new Response(data).blob() +} + +// Characters that are safe to leave unquoted in a POSIX shell, matching the +// set used by Python's shlex.quote (`[^\w@%+=:,./-]` is considered unsafe). +const UNSAFE_SHELL_CHAR = /[^\w@%+=:,./-]/ + +/** + * Quote a string for safe interpolation into a POSIX shell command. + * + * Faithful port of Python's `shlex.quote`: an empty string becomes `''`, + * values containing only safe characters are returned unchanged (keeping + * generated commands stable and cache-friendly), and anything else is wrapped + * in single quotes with embedded single quotes escaped as `'"'"'`. + */ +export function shellQuote(s: string): string { + if (s === '') { + return "''" + } + if (!UNSAFE_SHELL_CHAR.test(s)) { + return s + } + return "'" + s.replace(/'/g, "'\"'\"'") + "'" +} + +/** + * Prepare data for upload as a BodyInit, optionally gzip-compressed. + * + * Outside the browser, streams (and gzip-compressed data) are returned as + * `ReadableStream` so they can be uploaded without buffering in memory. + * Browsers don't support streaming request bodies, so data is buffered into + * a Blob there. + */ +export async function toUploadBody( + data: string | ArrayBuffer | Blob | ReadableStream, + gzip?: boolean +): Promise { + if (gzip) { + const stream = + data instanceof ReadableStream + ? data + : data instanceof Blob + ? data.stream() + : new Blob([data]).stream() + const compressed = stream.pipeThrough(new CompressionStream('gzip')) + return runtime === 'browser' ? new Response(compressed).blob() : compressed + } + + if (data instanceof ReadableStream && runtime !== 'browser') { + return data + } + + return toBlob(data) +} diff --git a/packages/js-sdk/src/volume/client.ts b/packages/js-sdk/src/volume/client.ts new file mode 100644 index 0000000..d63057b --- /dev/null +++ b/packages/js-sdk/src/volume/client.ts @@ -0,0 +1,139 @@ +import createClient from 'openapi-fetch' + +import type { components, paths } from './schema.gen' +import { defaultHeaders, getEnvVar } from '../api/metadata' +import { createApiFetch } from '../api/http2' +import { buildRequestSignal } from '../connectionConfig' +import { createApiLogger, Logger } from '../logs' +import type { Volume } from './index' + +const REQUEST_TIMEOUT_MS = 60_000 // 60 seconds +const FILE_TIMEOUT_MS = 3_600_000 // 1 hour + +export interface VolumeApiOpts { + /** + * E2B API key to use for authentication. + * + * @default E2B_API_KEY // environment variable + */ + token?: string + /** + * Domain to use for the volume API. + * + * @default E2B_DOMAIN // environment variable or `e2b.app` + */ + domain?: string + /** + * If true the SDK starts in the debug mode and connects to the local volume API server. + * @internal + * @default E2B_DEBUG // environment variable or `false` + */ + debug?: boolean + /** + * API Url to use for the API. + * @internal + * @default E2B_VOLUME_API_URL // environment variable or `https://api.${domain}` + */ + apiUrl?: string + /** + * Timeout for requests to the API in **milliseconds**. + * + * @default 60_000 // 60 seconds + */ + requestTimeoutMs?: number + /** + * Logger to use for logging messages. It can accept any object that implements `Logger` interface—for example, {@link console}. + */ + logger?: Logger + + /** + * Additional headers to send with the request. + */ + headers?: Record + + /** + * Proxy URL to use for requests. + * + * @example 'http://user:pass@127.0.0.1:8080' + */ + proxy?: string + + /** + * An optional `AbortSignal` that can be used to cancel the in-flight request. + * When the signal is aborted, the underlying `fetch` is aborted and the + * returned promise rejects with an `AbortError`. + */ + signal?: AbortSignal +} + +export class VolumeConnectionConfig { + readonly domain: string + readonly debug: boolean + readonly apiUrl: string + readonly token?: string + readonly headers?: Record + readonly logger?: Logger + readonly requestTimeoutMs: number + readonly signal?: AbortSignal + readonly proxy?: string + + constructor(volume: Volume, opts?: VolumeApiOpts) { + this.domain = opts?.domain || volume.domain || VolumeConnectionConfig.domain + this.debug = opts?.debug ?? volume.debug ?? VolumeConnectionConfig.debug + this.apiUrl = + opts?.apiUrl || + VolumeConnectionConfig.volumeApiUrl || + (this.debug ? 'http://localhost:8080' : `https://api.${this.domain}`) + this.token = opts?.token || volume.token + this.headers = opts?.headers + this.logger = opts?.logger + this.requestTimeoutMs = opts?.requestTimeoutMs ?? REQUEST_TIMEOUT_MS + this.signal = opts?.signal + this.proxy = opts?.proxy || volume.proxy + } + + private static get domain() { + return getEnvVar('E2B_DOMAIN') || 'e2b.app' + } + + private static get debug() { + return (getEnvVar('E2B_DEBUG') || 'false').toLowerCase() === 'true' + } + + private static get volumeApiUrl() { + return getEnvVar('E2B_VOLUME_API_URL') + } + + getSignal(requestTimeoutMs?: number, signal?: AbortSignal) { + return buildRequestSignal( + requestTimeoutMs ?? this.requestTimeoutMs, + signal ?? this.signal + ) + } +} + +/** + * Client for interacting with the E2B Volume API. + */ +class VolumeApiClient { + readonly api: ReturnType> + + constructor(config: VolumeConnectionConfig) { + this.api = createClient({ + baseUrl: config.apiUrl, + fetch: createApiFetch(config.proxy), + headers: { + ...defaultHeaders, + ...(config.token && { Authorization: `Bearer ${config.token}` }), + ...config.headers, + }, + }) + + if (config.logger) { + this.api.use(createApiLogger(config.logger)) + } + } +} + +export type { components as VolumeApiComponents, paths as VolumeApiPaths } +export { VolumeApiClient, FILE_TIMEOUT_MS } diff --git a/packages/js-sdk/src/volume/index.ts b/packages/js-sdk/src/volume/index.ts new file mode 100644 index 0000000..8b67389 --- /dev/null +++ b/packages/js-sdk/src/volume/index.ts @@ -0,0 +1,762 @@ +import { ApiClient, handleApiError, components as ApiComponents } from '../api' +import { + VolumeApiClient, + VolumeApiComponents, + VolumeConnectionConfig, + VolumeApiOpts, + FILE_TIMEOUT_MS, +} from './client' +import { + ConnectionConfig, + ConnectionOpts, + setupRequestController, + wrapStreamWithConnectionCleanup, +} from '../connectionConfig' +import { NotFoundError, VolumeError } from '../errors' +import { toUploadBody } from '../utils' +import { VolumeFileType } from './types' +import type { + VolumeAndToken, + VolumeEntryStat, + VolumeInfo, + VolumeMetadataOpts, + VolumeReadOpts, + VolumeWriteOpts, +} from './types' + +/** + * Convert API VolumeEntryStat to SDK VolumeEntryStat. + */ +function convertVolumeEntryStat( + entry: VolumeApiComponents['schemas']['VolumeEntryStat'] +): VolumeEntryStat { + return { + ...entry, + type: entry.type as VolumeFileType, + atime: new Date(entry.atime), + mtime: new Date(entry.mtime), + ctime: new Date(entry.ctime), + } +} + +/** + * Module for interacting with E2B volumes. + * + * Create a `Volume` instance to interact with a volume by its ID, + * or use the static methods to manage volumes. + */ +export class Volume { + /** + * Volume ID. + */ + readonly volumeId: string + + /** + * Volume name. + */ + readonly name: string + + /** + * Volume auth token. + */ + readonly token: string + + /** + * Domain used for constructing the volume API URL. + */ + readonly domain?: string + + /** + * Whether to use debug mode (connects to local volume API server). + */ + readonly debug?: boolean + + /** + * Proxy URL used for requests to the volume content API. + */ + readonly proxy?: string + + /** + * Create a local Volume instance with no API call. + * + * @param volumeId volume ID. + * @param name volume name. + * @param token volume auth token. + * @param domain domain for the volume API. + * @param debug whether to use debug mode. + * @param proxy proxy URL for the volume content API. + */ + constructor( + volumeId: string, + name: string, + token: string, + domain?: string, + debug?: boolean, + proxy?: string + ) { + this.volumeId = volumeId + this.name = name + this.token = token + this.domain = domain + this.debug = debug + this.proxy = proxy + } + + /** + * Create a new volume. + * + * @param name name of the volume. + * @param opts connection options. + * + * @returns new Volume instance. + */ + static async create(name: string, opts?: ConnectionOpts): Promise { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.POST('/volumes', { + body: { + name, + }, + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + const err = handleApiError(res, VolumeError) + if (err) { + throw err + } + + if (!res.data) { + throw new Error('Response data is missing') + } + + return new Volume( + res.data.volumeID, + res.data.name, + res.data.token, + config.domain, + config.debug, + config.proxy + ) + } + + /** + * Connect to an existing volume by ID. + * + * @param volumeId volume ID. + * @param opts connection options. + * + * @returns Volume instance. + */ + static async connect( + volumeId: string, + opts?: ConnectionOpts + ): Promise { + const config = new ConnectionConfig(opts) + const { name, token } = await Volume.getInfo(volumeId, opts) + return new Volume( + volumeId, + name, + token, + config.domain, + config.debug, + config.proxy + ) + } + + /** + * Get volume information. + * + * @param volumeId volume ID. + * @param opts connection options. + * + * @returns volume information. + */ + static async getInfo( + volumeId: string, + opts?: ConnectionOpts + ): Promise { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.GET('/volumes/{volumeID}', { + params: { + path: { + volumeID: volumeId, + }, + }, + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + if (res.response.status === 404) { + throw new NotFoundError(`Volume ${volumeId} not found`) + } + + const err = handleApiError(res, VolumeError) + if (err) { + throw err + } + + return { + volumeId: res.data!.volumeID, + name: res.data!.name, + token: res.data!.token, + } + } + + /** + * List all volumes. + * + * @param opts connection options. + * + * @returns list of volume information. + */ + static async list(opts?: ConnectionOpts): Promise { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.GET('/volumes', { + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + const err = handleApiError(res, VolumeError) + if (err) { + throw err + } + + return (res.data ?? []).map((vol: ApiComponents['schemas']['Volume']) => ({ + volumeId: vol.volumeID, + name: vol.name, + })) + } + + /** + * Destroy a volume. + * + * @param volumeId volume ID. + * @param opts connection options. + */ + static async destroy( + volumeId: string, + opts?: ConnectionOpts + ): Promise { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.DELETE('/volumes/{volumeID}', { + params: { + path: { + volumeID: volumeId, + }, + }, + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + if (res.response.status === 404) { + return false + } + + const err = handleApiError(res, VolumeError) + if (err) { + throw err + } + + return true + } + + /** + * List directory contents. + * + * @param path path to the directory. + * @param opts connection options. + * @param [opts.depth] number of layers deep to recurse into the directory (default: 1). + * + * @returns list of entries in the directory. + */ + async list( + path: string, + opts?: VolumeApiOpts & { depth?: number } + ): Promise { + const config = new VolumeConnectionConfig(this, opts) + const client = new VolumeApiClient(config) + + const res = await client.api.GET('/volumecontent/{volumeID}/dir', { + params: { + path: { + volumeID: this.volumeId, + }, + query: { + path, + depth: opts?.depth, + }, + }, + signal: config.getSignal(), + }) + + if (res.response.status === 404) { + throw new NotFoundError(`Path ${path} not found`) + } + + const err = handleApiError(res, VolumeError) + if (err) { + throw err + } + + // VolumeDirectoryListing is an array according to the spec + const entries = Array.isArray(res.data) ? res.data : [] + return entries.map(convertVolumeEntryStat) + } + + /** + * Create a directory. + * + * @param path path to the directory to create. + * @param options directory creation options. + * @param opts connection options. + */ + async makeDir( + path: string, + opts?: VolumeWriteOpts & VolumeApiOpts + ): Promise { + const config = new VolumeConnectionConfig(this, opts) + const client = new VolumeApiClient(config) + + const res = await client.api.POST('/volumecontent/{volumeID}/dir', { + params: { + path: { + volumeID: this.volumeId, + }, + query: { + path, + uid: opts?.uid, + gid: opts?.gid, + mode: opts?.mode, + force: opts?.force, + }, + }, + signal: config.getSignal(), + }) + + if (res.response.status === 404) { + throw new NotFoundError(`Path ${path} not found`) + } + + const err = handleApiError(res, VolumeError) + if (err) { + throw err + } + + if (!res.data) { + throw new Error('Response data is missing') + } + + return convertVolumeEntryStat( + res.data as VolumeApiComponents['schemas']['VolumeEntryStat'] + ) + } + + /** + * Get information about a file or directory. + * + * @param path path to the file or directory. + * @param opts connection options. + * + * @returns information about the entry. + */ + async getInfo(path: string, opts?: VolumeApiOpts): Promise { + const config = new VolumeConnectionConfig(this, opts) + const client = new VolumeApiClient(config) + + const res = await client.api.GET('/volumecontent/{volumeID}/path', { + params: { + path: { + volumeID: this.volumeId, + }, + query: { + path, + }, + }, + signal: config.getSignal(), + }) + + if (res.response.status === 404) { + throw new NotFoundError(`Path ${path} not found`) + } + + const err = handleApiError(res, VolumeError) + if (err) { + throw err + } + + if (!res.data) { + throw new Error('Response data is missing') + } + + return convertVolumeEntryStat( + res.data as VolumeApiComponents['schemas']['VolumeEntryStat'] + ) + } + + /** + * Check whether a file or directory exists. + * + * Uses {@link getInfo} under the hood. Returns `true` if the path exists, + * `false` if it does not (404). Other errors are rethrown. + * + * @param path path to the file or directory. + * @param opts connection options. + * + * @returns `true` if the path exists, `false` otherwise. + */ + async exists(path: string, opts?: VolumeApiOpts): Promise { + try { + await this.getInfo(path, opts) + return true + } catch (err) { + if (err instanceof NotFoundError) { + return false + } + throw err + } + } + + /** + * Update file or directory metadata. + * + * @param path path to the file or directory. + * @param metadata metadata to update (uid, gid, mode). + * @param opts connection options. + * + * @returns updated entry information. + */ + async updateMetadata( + path: string, + metadata: VolumeMetadataOpts, + opts?: VolumeApiOpts + ): Promise { + const config = new VolumeConnectionConfig(this, opts) + const client = new VolumeApiClient(config) + + const res = await client.api.PATCH('/volumecontent/{volumeID}/path', { + params: { + path: { + volumeID: this.volumeId, + }, + query: { + path, + }, + }, + body: { + uid: metadata.uid, + gid: metadata.gid, + mode: metadata.mode, + }, + signal: config.getSignal(), + }) + + if (res.response.status === 404) { + throw new NotFoundError(`Path ${path} not found`) + } + + const err = handleApiError(res, VolumeError) + if (err) { + throw err + } + + if (!res.data) { + throw new Error('Response data is missing') + } + + return convertVolumeEntryStat( + res.data as VolumeApiComponents['schemas']['VolumeEntryStat'] + ) + } + + /** + * Read file content as a `string`. + * + * You can pass `text`, `bytes`, `blob`, or `stream` to `opts.format` to change the return type. + * + * @param path path to the file. + * @param opts connection options. + * @param [opts.format] format of the file content—`text` by default. + * + * @returns file content as string + */ + async readFile( + path: string, + opts?: VolumeReadOpts & { format?: 'text' } + ): Promise + /** + * Read file content as a `Uint8Array`. + * + * You can pass `text`, `bytes`, `blob`, or `stream` to `opts.format` to change the return type. + * + * @param path path to the file. + * @param opts connection options. + * @param [opts.format] format of the file content—`bytes`. + * + * @returns file content as `Uint8Array` + */ + async readFile( + path: string, + opts?: VolumeReadOpts & { format: 'bytes' } + ): Promise + /** + * Read file content as a `Blob`. + * + * You can pass `text`, `bytes`, `blob`, or `stream` to `opts.format` to change the return type. + * + * @param path path to the file. + * @param opts connection options. + * @param [opts.format] format of the file content—`blob`. + * + * @returns file content as `Blob` + */ + async readFile( + path: string, + opts?: VolumeReadOpts & { format: 'blob' } + ): Promise + /** + * Read file content as a `ReadableStream`. + * + * You can pass `text`, `bytes`, `blob`, or `stream` to `opts.format` to change the return type. + * + * @param path path to the file. + * @param opts connection options. + * @param [opts.format] format of the file content—`stream`. + * + * @returns file content as `ReadableStream` + */ + async readFile( + path: string, + opts?: VolumeReadOpts & { format: 'stream' } + ): Promise> + async readFile( + path: string, + opts?: VolumeReadOpts & { + format?: 'text' | 'stream' | 'bytes' | 'blob' + } + ): Promise { + const format = opts?.format ?? 'text' + const config = new VolumeConnectionConfig(this, { + ...opts, + requestTimeoutMs: opts?.requestTimeoutMs ?? FILE_TIMEOUT_MS, + }) + const client = new VolumeApiClient(config) + + if (format === 'stream') { + // The request timeout bounds only the initial handshake; once the + // response arrives, the stream lives until it's consumed, cancelled, the + // user signal aborts, or the per-chunk idle timeout fires. Matches the + // sandbox `files.read` stream path. + const { controller, clearStartTimeout, cleanup } = setupRequestController( + config.requestTimeoutMs, + opts?.signal + ) + + try { + const res = await client.api.GET('/volumecontent/{volumeID}/file', { + params: { + path: { volumeID: this.volumeId }, + query: { path }, + }, + parseAs: 'stream', + signal: controller.signal, + }) + + if (res.response.status === 404) { + // Cancel the unconsumed body so the pooled connection is released + // before we propagate. + if (res.response.body && !res.response.bodyUsed) { + await res.response.body.cancel().catch(() => {}) + } + cleanup() + throw new NotFoundError(`Path ${path} not found`) + } + + const err = handleApiError(res, VolumeError) + if (err) { + if (res.response.body && !res.response.bodyUsed) { + await res.response.body.cancel().catch(() => {}) + } + cleanup() + throw err + } + + return wrapStreamWithConnectionCleanup( + res.data as ReadableStream | null, + { + clearStartTimeout, + cleanup, + controller, + idleTimeoutMs: opts?.streamIdleTimeoutMs ?? config.requestTimeoutMs, + } + ) + } catch (err) { + cleanup() + throw err + } + } + + const res = await client.api.GET('/volumecontent/{volumeID}/file', { + params: { + path: { + volumeID: this.volumeId, + }, + query: { + path, + }, + }, + parseAs: format === 'bytes' ? 'arrayBuffer' : format, + signal: config.getSignal(), + }) + + if (res.response.status === 404) { + throw new NotFoundError(`Path ${path} not found`) + } + + const err = handleApiError(res, VolumeError) + if (err) { + throw err + } + + // When the file is empty, `res.data` is `undefined`, so empty values are synthesized below. + if (format === 'bytes') { + return res.data instanceof ArrayBuffer + ? new Uint8Array(res.data) + : new Uint8Array() + } + + if (format === 'text') { + return typeof res.data === 'string' ? res.data : '' + } + + // format === 'blob' + return res.data instanceof Blob ? res.data : new Blob([]) + } + + /** + * Write content to a file. + * + * Writing to a file that doesn't exist creates the file. + * + * Writing to a file that already exists overwrites the file. + * + * @param path path to the file. + * @param data data to write to the file. Data can be a string, `ArrayBuffer`, `Blob`, or `ReadableStream`. Outside the browser, `ReadableStream` data is streamed to the API instead of being buffered in memory. + * @param options file creation options. + * @param opts connection options. + * + * @returns information about the written file + */ + async writeFile( + path: string, + data: string | ArrayBuffer | Blob | ReadableStream, + opts?: VolumeWriteOpts & VolumeApiOpts + ): Promise { + const config = new VolumeConnectionConfig(this, { + ...opts, + requestTimeoutMs: opts?.requestTimeoutMs ?? FILE_TIMEOUT_MS, + }) + const client = new VolumeApiClient(config) + + // `toUploadBody` returns a `ReadableStream` only when the body should be + // streamed (non-browser stream input); otherwise it buffers into a Blob. + const body = await toUploadBody(data) + const isStream = body instanceof ReadableStream + + // A streamed upload carries no client-side timeout: the socket-write + // "wire" isn't observable through fetch, and a stalled producer is the + // caller's own code, so a stuck streamed upload is bounded server-side (or + // via `opts.signal`). Buffered uploads keep the normal request timeout. + const signal = isStream ? opts?.signal : config.getSignal() + + const res = await client.api.PUT('/volumecontent/{volumeID}/file', { + params: { + path: { + volumeID: this.volumeId, + }, + query: { + path, + uid: opts?.uid, + gid: opts?.gid, + mode: opts?.mode, + force: opts?.force, + }, + }, + bodySerializer: () => body, + body: {} as any, + headers: { + 'Content-Type': 'application/octet-stream', + }, + signal, + // Streaming request bodies require half-duplex mode. + ...(isStream && { duplex: 'half' as const }), + }) + + if (res.response.status === 404) { + throw new NotFoundError(`Path ${path} not found`) + } + + const err = handleApiError(res, VolumeError) + if (err) { + throw err + } + + if (!res.data) { + throw new Error('Response data is missing') + } + + return convertVolumeEntryStat( + res.data as VolumeApiComponents['schemas']['VolumeEntryStat'] + ) + } + + /** + * Remove a file or directory. + * + * @param path path to the file or directory to remove. + * @param opts connection options. + */ + async remove(path: string, opts?: VolumeApiOpts): Promise { + const config = new VolumeConnectionConfig(this, opts) + const client = new VolumeApiClient(config) + + const res = await client.api.DELETE('/volumecontent/{volumeID}/path', { + params: { + path: { + volumeID: this.volumeId, + }, + query: { + path, + }, + }, + signal: config.getSignal(), + }) + + if (res.response.status === 404) { + throw new NotFoundError(`Path ${path} not found`) + } + + const err = handleApiError(res, VolumeError) + if (err) { + throw err + } + } +} + +export type { + VolumeInfo, + VolumeAndToken, + VolumeEntryStat, + VolumeMetadataOpts, + VolumeReadOpts, + VolumeWriteOpts, + // Deprecated aliases, kept for backwards compatibility. + VolumeMetadataOptions, + VolumeWriteOptions, +} from './types' + +export type { VolumeApiOpts, VolumeConnectionConfig } from './client' +export { VolumeFileType } from './types' diff --git a/packages/js-sdk/src/volume/schema.gen.ts b/packages/js-sdk/src/volume/schema.gen.ts new file mode 100644 index 0000000..1d5c479 --- /dev/null +++ b/packages/js-sdk/src/volume/schema.gen.ts @@ -0,0 +1,414 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/volumecontent/{volumeID}/dir": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List directory contents */ + get: { + parameters: { + query: { + /** @description Number of layers deep to recurse into the directory */ + depth?: number; + path: components["parameters"]["path"]; + }; + header?: never; + path: { + volumeID: components["parameters"]["volumeID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully retrieved a directory listing */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VolumeDirectoryListing"]; + }; + }; + /** @description Invalid path provided */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description path not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 500: components["responses"]["500"]; + }; + }; + put?: never; + /** @description Create a directory */ + post: { + parameters: { + query: { + /** @description Create the parents of a directory if they don't exist */ + force?: boolean; + /** @description Group ID of the created directory */ + gid?: number; + /** @description Mode of the created directory */ + mode?: number; + path: components["parameters"]["path"]; + /** @description User ID of the created directory */ + uid?: number; + }; + header?: never; + path: { + volumeID: components["parameters"]["volumeID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully created a directory */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VolumeEntryStat"]; + }; + }; + /** @description path not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/volumecontent/{volumeID}/file": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Download file */ + get: { + parameters: { + query: { + path: components["parameters"]["path"]; + }; + header?: never; + path: { + volumeID: components["parameters"]["volumeID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully downloaded a file */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/octet-stream": string; + }; + }; + /** @description path not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 500: components["responses"]["500"]; + }; + }; + /** @description Upload file */ + put: { + parameters: { + query: { + /** @description Force overwrite of an existing file */ + force?: boolean; + /** @description Group ID of the uploaded file */ + gid?: number; + /** @description Mode of the uploaded file */ + mode?: number; + path: components["parameters"]["path"]; + /** @description User ID of the uploaded file */ + uid?: number; + }; + header?: never; + path: { + volumeID: components["parameters"]["volumeID"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/octet-stream": string; + }; + }; + responses: { + /** @description Successfully created a file */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VolumeEntryStat"]; + }; + }; + /** @description path not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 500: components["responses"]["500"]; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/volumecontent/{volumeID}/path": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get path information */ + get: { + parameters: { + query: { + path: components["parameters"]["path"]; + }; + header?: never; + path: { + volumeID: components["parameters"]["volumeID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully retrieved path information */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VolumeEntryStat"]; + }; + }; + 404: components["responses"]["404"]; + }; + }; + put?: never; + post?: never; + /** @description Delete a path */ + delete: { + parameters: { + query: { + path: components["parameters"]["path"]; + }; + header?: never; + path: { + volumeID: components["parameters"]["volumeID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully deleted a path */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 404: components["responses"]["404"]; + }; + }; + options?: never; + head?: never; + /** @description Update path metadata */ + patch: { + parameters: { + query: { + path: components["parameters"]["path"]; + }; + header?: never; + path: { + volumeID: components["parameters"]["volumeID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: uint32 */ + gid?: number; + /** Format: uint32 */ + mode?: number; + /** Format: uint32 */ + uid?: number; + }; + }; + }; + responses: { + /** @description Successfully updated a file's metadata */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VolumeEntryStat"]; + }; + }; + /** @description Invalid metadata provided */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description path not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Error: { + /** @description Error code */ + code: string; + /** @description Error message */ + message: string; + }; + VolumeDirectoryListing: components["schemas"]["VolumeEntryStat"][]; + VolumeEntryStat: { + /** Format: date-time */ + atime: string; + /** Format: date-time */ + ctime: string; + /** Format: uint32 */ + gid: number; + /** Format: uint32 */ + mode: number; + /** Format: date-time */ + mtime: string; + name: string; + path: string; + /** Format: int64 */ + size: number; + target?: string; + /** @enum {string} */ + type: "unknown" | "file" | "directory" | "symlink"; + /** Format: uint32 */ + uid: number; + }; + }; + responses: { + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Authentication error */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + parameters: { + path: string; + volumeID: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/packages/js-sdk/src/volume/types.ts b/packages/js-sdk/src/volume/types.ts new file mode 100644 index 0000000..e5a9526 --- /dev/null +++ b/packages/js-sdk/src/volume/types.ts @@ -0,0 +1,119 @@ +import { VolumeApiComponents, VolumeApiOpts } from './client' + +/** + * File type enum. + */ +export enum VolumeFileType { + UNKNOWN = 'unknown', + FILE = 'file', + DIRECTORY = 'directory', + SYMLINK = 'symlink', +} + +/** + * Information about a volume. + */ +export type VolumeInfo = { + /** + * Volume ID. + */ + volumeId: string + + /** + * Volume name. + */ + name: string +} + +/** + * Information about a volume and its auth token. + */ +export type VolumeAndToken = VolumeInfo & { + /** + * Volume auth token. + */ + token: string +} + +/** + * Volume entry stat with dates converted to Date objects. + */ +export type VolumeEntryStat = Omit< + VolumeApiComponents['schemas']['VolumeEntryStat'], + 'atime' | 'mtime' | 'ctime' | 'type' +> & { + /** + * Access time as a Date object. + */ + atime: Date + + /** + * Modification time as a Date object. + */ + mtime: Date + + /** + * Creation time as a Date object. + */ + ctime: Date + + /** + * File type. + */ + type: VolumeFileType +} + +/** + * Options for updating file metadata. + */ +export type VolumeMetadataOpts = { + /** + * User ID of the file or directory. + */ + uid?: number + + /** + * Group ID of the file or directory. + */ + gid?: number + + /** + * Mode of the file or directory. + */ + mode?: number +} + +/** + * Options for reading files from a volume. + */ +export type VolumeReadOpts = VolumeApiOpts & { + /** + * Idle timeout for a streamed read (`format: 'stream'`) in **milliseconds**: + * abort if no chunk arrives from the server within this window *while + * reading*. It bounds only the wire — a slow or paused consumer never trips + * it (a consumer that holds the stream but stops reading is reclaimed + * server-side). Defaults to the request timeout; pass `0` to disable. + */ + streamIdleTimeoutMs?: number +} + +/** + * Options for file and directory operations. + */ +export type VolumeWriteOpts = VolumeMetadataOpts & { + /** + * For makeDir: Create parent directories if they don't exist. + * For writeFile: Force overwrite of an existing file. + */ + force?: boolean +} + +/** + * @deprecated Use {@link VolumeMetadataOpts} instead. + */ +export type VolumeMetadataOptions = VolumeMetadataOpts + +/** + * @deprecated Use {@link VolumeWriteOpts} instead. + */ +export type VolumeWriteOptions = VolumeWriteOpts diff --git a/packages/js-sdk/tests/api/handleApiError.test.ts b/packages/js-sdk/tests/api/handleApiError.test.ts new file mode 100644 index 0000000..6d00483 --- /dev/null +++ b/packages/js-sdk/tests/api/handleApiError.test.ts @@ -0,0 +1,133 @@ +import { assert, test, describe } from 'vitest' +import { handleApiError } from '../../src/api' +import { + AuthenticationError, + RateLimitError, + SandboxError, +} from '../../src/errors' + +function createMockResponse( + status: number, + error: unknown, + data?: unknown +): { + response: { status: number; ok: boolean } + error: unknown + data: unknown +} { + return { + response: { status, ok: status >= 200 && status < 300 }, + error, + data, + } +} + +describe('handleApiError', () => { + describe('without content', () => { + // openapi-fetch leaves `error` undefined for non-2xx responses with + // Content-Length: 0 + test('catches 404 with undefined error', () => { + const res = createMockResponse(404, undefined) + const err = handleApiError(res as any) + assert.instanceOf(err, SandboxError) + assert.include(err?.message, '404') + }) + + test('catches 500 with undefined error', () => { + const res = createMockResponse(500, undefined) + const err = handleApiError(res as any) + assert.instanceOf(err, SandboxError) + assert.include(err?.message, '500') + }) + + test('returns AuthenticationError for 401 with undefined error', () => { + const res = createMockResponse(401, undefined) + const err = handleApiError(res as any) + assert.instanceOf(err, AuthenticationError) + assert.include(err?.message, 'Unauthorized') + }) + }) + + describe('with empty error body', () => { + test('catches 404 with empty string error', () => { + const res = createMockResponse(404, '') + const err = handleApiError(res as any) + assert.instanceOf(err, SandboxError) + assert.include(err?.message, '404') + }) + + test('catches 400 with empty string error', () => { + const res = createMockResponse(400, '') + const err = handleApiError(res as any) + assert.instanceOf(err, SandboxError) + assert.include(err?.message, '400') + }) + + test('catches 500 with empty string error', () => { + const res = createMockResponse(500, '') + const err = handleApiError(res as any) + assert.instanceOf(err, SandboxError) + assert.include(err?.message, '500') + }) + }) + + describe('with JSON error body', () => { + test('catches 404 with message', () => { + const res = createMockResponse(404, { code: 404, message: 'Not found' }) + const err = handleApiError(res as any) + assert.instanceOf(err, SandboxError) + assert.include(err?.message, 'Not found') + }) + + test('catches 400 with message', () => { + const res = createMockResponse(400, { code: 400, message: 'Bad request' }) + const err = handleApiError(res as any) + assert.instanceOf(err, SandboxError) + assert.include(err?.message, 'Bad request') + }) + }) + + describe('special status codes', () => { + test('returns AuthenticationError for 401', () => { + const res = createMockResponse(401, { message: 'Invalid token' }) + const err = handleApiError(res as any) + assert.instanceOf(err, AuthenticationError) + assert.include(err?.message, 'Unauthorized') + }) + + test('returns AuthenticationError for 401 with empty body', () => { + const res = createMockResponse(401, '') + const err = handleApiError(res as any) + assert.instanceOf(err, AuthenticationError) + assert.include(err?.message, 'Unauthorized') + }) + + test('returns RateLimitError for 429', () => { + const res = createMockResponse(429, { message: 'Too many requests' }) + const err = handleApiError(res as any) + assert.instanceOf(err, RateLimitError) + assert.include(err?.message, 'Rate limit') + }) + + test('returns RateLimitError for 429 with empty body', () => { + const res = createMockResponse(429, '') + const err = handleApiError(res as any) + assert.instanceOf(err, RateLimitError) + assert.include(err?.message, 'Rate limit') + }) + }) + + describe('success responses', () => { + test('returns undefined for 200 success', () => { + const res = createMockResponse(200, undefined, { id: '123' }) + const err = handleApiError(res as any) + assert.isUndefined(err) + }) + + test('returns undefined for 201 success', () => { + const res = createMockResponse(201, undefined, { id: '123' }) + const err = handleApiError(res as any) + assert.isUndefined(err) + }) + }) +}) diff --git a/packages/js-sdk/tests/api/http2.test.ts b/packages/js-sdk/tests/api/http2.test.ts new file mode 100644 index 0000000..83ef5e4 --- /dev/null +++ b/packages/js-sdk/tests/api/http2.test.ts @@ -0,0 +1,137 @@ +import { afterEach, expect, test, vi } from 'vitest' + +afterEach(() => { + vi.restoreAllMocks() + vi.resetModules() + vi.doUnmock('undici') + vi.doUnmock('../../src/utils') + delete process.env.E2B_API_CONNECTIONS + delete process.env.E2B_API_INFLIGHT_REQUESTS +}) + +test('uses undici with a bounded HTTP/2 dispatcher for API requests', async () => { + const agents: Array<{ allowH2?: boolean; connections?: number }> = [] + const requests: Array<{ init?: RequestInit & { dispatcher?: unknown } }> = [] + + class Agent { + constructor(options: { allowH2?: boolean; connections?: number }) { + agents.push(options) + } + } + + const undiciFetch = vi.fn((input, init) => { + requests.push({ init }) + return Promise.resolve(new Response('ok')) + }) + const loadUndici = vi.fn(() => Promise.resolve({ Agent, fetch: undiciFetch })) + + const { createApiFetchForRuntime } = await import('../../src/api/http2') + + const fetcher = createApiFetchForRuntime('node', { + connectionLimit: 100, + loadUndici, + }) + await fetcher('https://example.com/sandboxes') + + expect(loadUndici).toHaveBeenCalledOnce() + expect(agents).toEqual([{ allowH2: true, connections: 100 }]) + expect(requests[0].init?.dispatcher).toBeInstanceOf(Agent) +}) + +test('uses a ProxyAgent dispatcher when a proxy is configured', async () => { + const proxyAgents: Array<{ + uri?: string + allowH2?: boolean + connections?: number + }> = [] + const agents: Array = [] + const requests: Array<{ init?: RequestInit & { dispatcher?: unknown } }> = [] + + class Agent { + constructor() { + agents.push(this) + } + } + + class ProxyAgent { + constructor(options: { + uri?: string + allowH2?: boolean + connections?: number + }) { + proxyAgents.push(options) + } + } + + const undiciFetch = vi.fn((input, init) => { + requests.push({ init }) + return Promise.resolve(new Response('ok')) + }) + const loadUndici = vi.fn(() => + Promise.resolve({ Agent, ProxyAgent, fetch: undiciFetch }) + ) + + const { createApiFetchForRuntime } = await import('../../src/api/http2') + + const fetcher = createApiFetchForRuntime('node', { + connectionLimit: 100, + proxy: 'http://user:pass@127.0.0.1:8080', + loadUndici, + }) + await fetcher('https://example.com/sandboxes') + + expect(agents).toHaveLength(0) + expect(proxyAgents).toEqual([ + { + uri: 'http://user:pass@127.0.0.1:8080', + allowH2: true, + connections: 100, + }, + ]) + expect(requests[0].init?.dispatcher).toBeInstanceOf(ProxyAgent) +}) + +test('caches API fetchers per proxy', async () => { + const { createApiFetch } = await import('../../src/api/http2') + + const noProxy = createApiFetch() + const proxyA = createApiFetch('http://127.0.0.1:8080') + const proxyB = createApiFetch('http://127.0.0.1:9090') + + expect(createApiFetch()).toBe(noProxy) + expect(createApiFetch('http://127.0.0.1:8080')).toBe(proxyA) + expect(proxyA).not.toBe(noProxy) + expect(proxyA).not.toBe(proxyB) +}) + +test('getApiConnectionLimit throws on a malformed env value', async () => { + process.env.E2B_API_CONNECTIONS = 'not-a-number' + + const { getApiConnectionLimit } = await import('../../src/api/http2') + + expect(() => getApiConnectionLimit()).toThrow(/E2B_API_CONNECTIONS/) +}) + +test('getApiInflightLimit throws on a malformed env value', async () => { + process.env.E2B_API_INFLIGHT_REQUESTS = 'not-a-number' + + const { getApiInflightLimit } = await import('../../src/api/http2') + + expect(() => getApiInflightLimit()).toThrow(/E2B_API_INFLIGHT_REQUESTS/) +}) + +test('getApiInflightLimit returns 0 when explicitly disabled', async () => { + process.env.E2B_API_INFLIGHT_REQUESTS = '0' + + const { getApiInflightLimit } = await import('../../src/api/http2') + + expect(getApiInflightLimit()).toBe(0) +}) + +test('getApiInflightLimit throws on negative env value', async () => { + process.env.E2B_API_INFLIGHT_REQUESTS = '-5' + + const { getApiInflightLimit } = await import('../../src/api/http2') + + expect(() => getApiInflightLimit()).toThrow(/E2B_API_INFLIGHT_REQUESTS=-5/) +}) diff --git a/packages/js-sdk/tests/api/inflight.test.ts b/packages/js-sdk/tests/api/inflight.test.ts new file mode 100644 index 0000000..f9ea00b --- /dev/null +++ b/packages/js-sdk/tests/api/inflight.test.ts @@ -0,0 +1,75 @@ +import { expect, test, vi } from 'vitest' + +import { limitConcurrency } from '../../src/api/inflight' + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +test('limitConcurrency queues requests over the cap and releases on response', async () => { + const gate = deferred() + let secondStarted = false + const inner = vi.fn(async (input: RequestInfo | URL) => { + if (String(input).endsWith('/first')) return gate.promise + secondStarted = true + return new Response('second') + }) as unknown as typeof fetch + + const limited = limitConcurrency(inner, 1) + const first = limited('https://example.com/first') + const second = limited('https://example.com/second') + + await Promise.resolve() + await Promise.resolve() + expect(secondStarted).toBe(false) + + gate.resolve(new Response('first')) + expect(await (await first).text()).toBe('first') + expect(await (await second).text()).toBe('second') + expect(secondStarted).toBe(true) +}) + +test('limitConcurrency releases when the underlying fetch rejects', async () => { + let calls = 0 + const inner = vi.fn(async () => { + calls++ + if (calls === 1) throw new Error('boom') + return new Response('ok') + }) as unknown as typeof fetch + + const limited = limitConcurrency(inner, 1) + await expect(limited('https://example.com/a')).rejects.toThrow('boom') + + // Slot should be free for the next request. + const res = await limited('https://example.com/b') + expect(await res.text()).toBe('ok') +}) + +test('limitConcurrency aborts queued requests when their signal fires', async () => { + const gate = deferred() + const inner = vi.fn(async () => gate.promise) as unknown as typeof fetch + const limited = limitConcurrency(inner, 1) + + // Occupy the only slot. + const first = limited('https://example.com/first') + + const controller = new AbortController() + const queued = limited('https://example.com/queued', { + signal: controller.signal, + }) + + // Abort the queued request before the slot frees. + controller.abort() + await expect(queued).rejects.toMatchObject({ name: 'AbortError' }) + + // Release the first request to make sure cleanup did not break the slot. + gate.resolve(new Response('done')) + const resp = await first + expect(await resp.text()).toBe('done') +}) diff --git a/packages/js-sdk/tests/api/info.test.ts b/packages/js-sdk/tests/api/info.test.ts new file mode 100644 index 0000000..0e3ca7c --- /dev/null +++ b/packages/js-sdk/tests/api/info.test.ts @@ -0,0 +1,10 @@ +import { expect } from 'vitest' + +import { sandboxTest, isDebug } from '../setup.js' +import { Sandbox } from '../../src' + +sandboxTest.skipIf(isDebug)('get sandbox info', async ({ sandbox }) => { + const info = await Sandbox.getInfo(sandbox.sandboxId) + expect(info).toBeDefined() + expect(info.sandboxId).toBe(sandbox.sandboxId) +}) diff --git a/packages/js-sdk/tests/api/kill.test.ts b/packages/js-sdk/tests/api/kill.test.ts new file mode 100644 index 0000000..04cd45f --- /dev/null +++ b/packages/js-sdk/tests/api/kill.test.ts @@ -0,0 +1,21 @@ +import { expect } from 'vitest' + +import { sandboxTest, isDebug } from '../setup.js' +import { Sandbox } from '../../src' + +sandboxTest.skipIf(isDebug)( + 'kill existing sandbox', + async ({ sandbox, sandboxTestId }) => { + await Sandbox.kill(sandbox.sandboxId) + + const paginator = Sandbox.list({ + query: { state: ['running'], metadata: { sandboxTestId } }, + }) + const sandboxes = await paginator.nextItems() + expect(sandboxes.map((s) => s.sandboxId)).not.toContain(sandbox.sandboxId) + } +) + +sandboxTest.skipIf(isDebug)('kill non-existing sandbox', async () => { + await expect(Sandbox.kill('nonexistingsandbox')).resolves.toBe(false) +}) diff --git a/packages/js-sdk/tests/api/list.test.ts b/packages/js-sdk/tests/api/list.test.ts new file mode 100644 index 0000000..3960b9f --- /dev/null +++ b/packages/js-sdk/tests/api/list.test.ts @@ -0,0 +1,440 @@ +import { assert } from 'vitest' +import { randomUUID } from 'crypto' + +import { Sandbox, SandboxInfo } from '../../src' +import { sandboxTest, isDebug } from '../setup.js' + +sandboxTest.skipIf(isDebug)( + 'list sandboxes', + async ({ sandbox, sandboxTestId }) => { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId } }, + }) + const sandboxes = await paginator.nextItems() + + assert.isAtLeast(sandboxes.length, 1) + + const found = sandboxes.some((s) => s.sandboxId === sandbox.sandboxId) + assert.isTrue(found) + } +) + +sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => { + const uniqueId = randomUUID() + const extraSbx = await Sandbox.create({ metadata: { uniqueId } }) + + try { + const paginator = Sandbox.list({ + query: { metadata: { uniqueId } }, + }) + const sandboxes = await paginator.nextItems() + + assert.equal(sandboxes.length, 1) + assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId) + } finally { + await extraSbx.kill() + } +}) + +sandboxTest.skipIf(isDebug)( + 'list running sandboxes', + async ({ sandboxTestId }) => { + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + + try { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId }, state: ['running'] }, + }) + const sandboxes = await paginator.nextItems() + + assert.isAtLeast(sandboxes.length, 1) + + // Verify our running sandbox is in the list + const found = sandboxes.some( + (s) => s.sandboxId === extraSbx.sandboxId && s.state === 'running' + ) + assert.isTrue(found) + } finally { + await extraSbx.kill() + } + } +) + +sandboxTest.skipIf(isDebug)( + 'list paused sandboxes', + async ({ sandboxTestId }) => { + // Create and pause a sandbox + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + await extraSbx.betaPause() + + try { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId }, state: ['paused'] }, + }) + const sandboxes = await paginator.nextItems() + + assert.isAtLeast(sandboxes.length, 1) + + // Verify our paused sandbox is in the list + const found = sandboxes.some( + (s) => s.sandboxId === extraSbx.sandboxId && s.state === 'paused' + ) + assert.isTrue(found) + } finally { + await extraSbx.kill() + } + } +) + +sandboxTest.skipIf(isDebug)( + 'paginate running sandboxes', + async ({ sandbox, sandboxTestId }) => { + // Create extra sandboxes + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + + try { + // Test pagination with limit + const paginator = Sandbox.list({ + limit: 1, + query: { metadata: { sandboxTestId }, state: ['running'] }, + }) + const sandboxes = await paginator.nextItems() + + // Check first page + assert.equal(sandboxes.length, 1) + assert.equal(sandboxes[0].state, 'running') + assert.isTrue(paginator.hasNext) + assert.notEqual(paginator.nextToken, undefined) + assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId) + + // Get second page + const sandboxes2 = await paginator.nextItems() + + // Check second page + assert.equal(sandboxes2.length, 1) + assert.equal(sandboxes2[0].state, 'running') + assert.isFalse(paginator.hasNext) + assert.equal(paginator.nextToken, undefined) + assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId) + } finally { + await extraSbx.kill() + } + } +) + +sandboxTest.skipIf(isDebug)( + 'paginate paused sandboxes', + async ({ sandbox, sandboxTestId }) => { + await sandbox.betaPause() + + // Create extra paused sandbox + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + await extraSbx.betaPause() + + try { + // Test pagination with limit + const paginator = Sandbox.list({ + limit: 1, + query: { metadata: { sandboxTestId }, state: ['paused'] }, + }) + const sandboxes = await paginator.nextItems() + + // Check first page + assert.equal(sandboxes.length, 1) + assert.equal(sandboxes[0].state, 'paused') + assert.isTrue(paginator.hasNext) + assert.notEqual(paginator.nextToken, undefined) + assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId) + + // Get second page + const sandboxes2 = await paginator.nextItems() + + // Check second page + assert.equal(sandboxes2.length, 1) + assert.equal(sandboxes2[0].state, 'paused') + assert.isFalse(paginator.hasNext) + assert.equal(paginator.nextToken, undefined) + assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId) + } finally { + await extraSbx.kill() + } + } +) + +sandboxTest.skipIf(isDebug)( + 'paginate running and paused sandboxes', + async ({ sandbox, sandboxTestId }) => { + // Create extra sandbox + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + + // Pause the extra sandbox + await extraSbx.betaPause() + + try { + // Test pagination with limit + const paginator = Sandbox.list({ + limit: 1, + query: { + metadata: { sandboxTestId }, + state: ['running', 'paused'], + }, + }) + const sandboxes = await paginator.nextItems() + + // Check first page + assert.equal(sandboxes.length, 1) + assert.equal(sandboxes[0].state, 'paused') + assert.isTrue(paginator.hasNext) + assert.notEqual(paginator.nextToken, undefined) + assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId) + + // Get second page + const sandboxes2 = await paginator.nextItems() + + // Check second page + assert.equal(sandboxes2.length, 1) + assert.equal(sandboxes2[0].state, 'running') + assert.isFalse(paginator.hasNext) + assert.equal(paginator.nextToken, undefined) + assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId) + } finally { + await extraSbx.kill() + } + } +) + +sandboxTest.skipIf(isDebug)( + 'paginate iterator', + async ({ sandbox, sandboxTestId }) => { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId } }, + }) + const sandboxes: SandboxInfo[] = [] + + while (paginator.hasNext) { + const sbxs = await paginator.nextItems() + sandboxes.push(...sbxs) + } + + assert.isAtLeast(sandboxes.length, 1) + assert.isTrue(sandboxes.some((s) => s.sandboxId === sandbox.sandboxId)) + } +) + +sandboxTest.skipIf(isDebug)( + 'list sandboxes', + async ({ sandbox, sandboxTestId }) => { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId } }, + }) + const sandboxes = await paginator.nextItems() + + assert.isAtLeast(sandboxes.length, 1) + + const found = sandboxes.some((s) => s.sandboxId === sandbox.sandboxId) + assert.isTrue(found) + } +) + +sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => { + const uniqueId = randomUUID() + const extraSbx = await Sandbox.create({ metadata: { uniqueId } }) + + try { + const paginator = Sandbox.list({ + query: { metadata: { uniqueId } }, + }) + const sandboxes = await paginator.nextItems() + + assert.equal(sandboxes.length, 1) + assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId) + } finally { + await extraSbx.kill() + } +}) + +sandboxTest.skipIf(isDebug)( + 'list running sandboxes', + async ({ sandboxTestId }) => { + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + + try { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId }, state: ['running'] }, + }) + const sandboxes = await paginator.nextItems() + + assert.isAtLeast(sandboxes.length, 1) + + // Verify our running sandbox is in the list + const found = sandboxes.some( + (s) => s.sandboxId === extraSbx.sandboxId && s.state === 'running' + ) + assert.isTrue(found) + } finally { + await extraSbx.kill() + } + } +) + +sandboxTest.skipIf(isDebug)( + 'list paused sandboxes', + async ({ sandboxTestId }) => { + // Create and pause a sandbox + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + await Sandbox.betaPause(extraSbx.sandboxId) + + try { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId }, state: ['paused'] }, + }) + const sandboxes = await paginator.nextItems() + + assert.isAtLeast(sandboxes.length, 1) + + // Verify our paused sandbox is in the list + const found = sandboxes.some( + (s) => s.sandboxId === extraSbx.sandboxId && s.state === 'paused' + ) + assert.isTrue(found) + } finally { + await extraSbx.kill() + } + } +) + +sandboxTest.skipIf(isDebug)( + 'paginate running sandboxes', + async ({ sandbox, sandboxTestId }) => { + // Create extra sandboxes + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + + try { + // Test pagination with limit + const paginator = Sandbox.list({ + limit: 1, + query: { metadata: { sandboxTestId }, state: ['running'] }, + }) + const sandboxes = await paginator.nextItems() + + // Check first page + assert.equal(sandboxes.length, 1) + assert.equal(sandboxes[0].state, 'running') + assert.isTrue(paginator.hasNext) + assert.notEqual(paginator.nextToken, undefined) + assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId) + + // Get second page + const sandboxes2 = await paginator.nextItems() + + // Check second page + assert.equal(sandboxes2.length, 1) + assert.equal(sandboxes2[0].state, 'running') + assert.isFalse(paginator.hasNext) + assert.equal(paginator.nextToken, undefined) + assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId) + } finally { + await extraSbx.kill() + } + } +) + +sandboxTest.skipIf(isDebug)( + 'paginate paused sandboxes', + async ({ sandbox, sandboxTestId }) => { + await Sandbox.betaPause(sandbox.sandboxId) + + // Create extra paused sandbox + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + await Sandbox.betaPause(extraSbx.sandboxId) + + try { + // Test pagination with limit + const paginator = Sandbox.list({ + limit: 1, + query: { metadata: { sandboxTestId }, state: ['paused'] }, + }) + const sandboxes = await paginator.nextItems() + + // Check first page + assert.equal(sandboxes.length, 1) + assert.equal(sandboxes[0].state, 'paused') + assert.isTrue(paginator.hasNext) + assert.notEqual(paginator.nextToken, undefined) + assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId) + + // Get second page + const sandboxes2 = await paginator.nextItems() + + // Check second page + assert.equal(sandboxes2.length, 1) + assert.equal(sandboxes2[0].state, 'paused') + assert.isFalse(paginator.hasNext) + assert.equal(paginator.nextToken, undefined) + assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId) + } finally { + await extraSbx.kill() + } + } +) + +sandboxTest.skipIf(isDebug)( + 'paginate running and paused sandboxes', + async ({ sandbox, sandboxTestId }) => { + // Create extra sandbox + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + + // Pause the extra sandbox + await Sandbox.betaPause(sandbox.sandboxId) + + try { + // Test pagination with limit + const paginator = Sandbox.list({ + limit: 1, + query: { + metadata: { sandboxTestId }, + state: ['running', 'paused'], + }, + }) + const sandboxes = await paginator.nextItems() + + // Check first page + assert.equal(sandboxes.length, 1) + assert.equal(sandboxes[0].state, 'running') + + assert.isTrue(paginator.hasNext) + assert.notEqual(paginator.nextToken, undefined) + assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId) + + // Get second page + const sandboxes2 = await paginator.nextItems() + + // Check second page + assert.equal(sandboxes2.length, 1) + assert.equal(sandboxes2[0].state, 'paused') + assert.isFalse(paginator.hasNext) + assert.equal(paginator.nextToken, undefined) + assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId) + } finally { + await extraSbx.kill() + } + } +) + +sandboxTest.skipIf(isDebug)( + 'paginate iterator', + async ({ sandbox, sandboxTestId }) => { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId } }, + }) + const sandboxes: SandboxInfo[] = [] + + while (paginator.hasNext) { + const sbxs = await paginator.nextItems() + sandboxes.push(...sbxs) + } + + assert.isAtLeast(sandboxes.length, 1) + assert.isTrue(sandboxes.some((s) => s.sandboxId === sandbox.sandboxId)) + } +) diff --git a/packages/js-sdk/tests/api/snapshot.test.ts b/packages/js-sdk/tests/api/snapshot.test.ts new file mode 100644 index 0000000..828389d --- /dev/null +++ b/packages/js-sdk/tests/api/snapshot.test.ts @@ -0,0 +1,26 @@ +import { assert } from 'vitest' + +import { sandboxTest, isDebug } from '../setup.js' +import { Sandbox } from '../../src' + +sandboxTest.skipIf(isDebug)('pause sandbox', async ({ sandbox }) => { + await Sandbox.pause(sandbox.sandboxId) + assert.isFalse( + await sandbox.isRunning(), + 'Sandbox should not be running after pause' + ) +}) + +sandboxTest.skipIf(isDebug)('resume sandbox', async ({ sandbox }) => { + await Sandbox.pause(sandbox.sandboxId) + assert.isFalse( + await sandbox.isRunning(), + 'Sandbox should not be running after pause' + ) + + await Sandbox.connect(sandbox.sandboxId) + assert.isTrue( + await sandbox.isRunning(), + 'Sandbox should be running after resume' + ) +}) diff --git a/packages/js-sdk/tests/api/validateApiKey.test.ts b/packages/js-sdk/tests/api/validateApiKey.test.ts new file mode 100644 index 0000000..af710b0 --- /dev/null +++ b/packages/js-sdk/tests/api/validateApiKey.test.ts @@ -0,0 +1,91 @@ +import { assert, test, describe, vi, afterEach } from 'vitest' +import { ApiClient, validateApiKey } from '../../src/api' +import { ConnectionConfig } from '../../src/connectionConfig' +import { AuthenticationError } from '../../src/errors' + +describe('validateApiKey', () => { + const validKey = 'e2b_' + '0123456789abcdef'.repeat(2) + '01234567' + + test('accepts a well-formed key', () => { + assert.doesNotThrow(() => validateApiKey(validKey)) + }) + + test('rejects a key without the e2b_ prefix', () => { + assert.throws( + () => validateApiKey('sk_' + '0'.repeat(40)), + AuthenticationError, + /Invalid API key format/ + ) + }) + + test('accepts a key with a non-default body length', () => { + assert.doesNotThrow(() => validateApiKey('e2b_' + '0'.repeat(20))) + }) + + test('rejects an empty body after the prefix', () => { + assert.throws( + () => validateApiKey('e2b_'), + AuthenticationError, + /Invalid API key format/ + ) + }) + + test('rejects a key with non-hex characters in the body', () => { + assert.throws( + () => validateApiKey('e2b_' + 'z'.repeat(40)), + AuthenticationError, + /Invalid API key format/ + ) + }) + + test('error message includes an example token', () => { + try { + validateApiKey('nope') + assert.fail('expected validateApiKey to throw') + } catch (err) { + assert.instanceOf(err, AuthenticationError) + assert.match( + (err as Error).message, + /e2b_0{40}/, + 'expected example token in error message' + ) + } + }) +}) + +describe('ApiClient API key validation', () => { + test('throws on a malformed key by default', () => { + const config = new ConnectionConfig({ apiKey: 'not-a-valid-key' }) + assert.throws(() => new ApiClient(config), AuthenticationError) + }) + + test('skips validation when validateApiKey is false', () => { + const config = new ConnectionConfig({ + apiKey: 'not-a-valid-key', + validateApiKey: false, + }) + assert.doesNotThrow(() => new ApiClient(config)) + }) +}) + +describe('ApiClient API key requirement', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + test('throws when no API key is supplied', () => { + vi.stubEnv('E2B_API_KEY', '') + const config = new ConnectionConfig({}) + assert.throws( + () => new ApiClient(config), + AuthenticationError, + /API key is required/ + ) + }) + + test('does not require an API key when requireApiKey is false', () => { + vi.stubEnv('E2B_API_KEY', '') + const config = new ConnectionConfig({}) + assert.doesNotThrow(() => new ApiClient(config, { requireApiKey: false })) + }) +}) diff --git a/packages/js-sdk/tests/cmdHelper.ts b/packages/js-sdk/tests/cmdHelper.ts new file mode 100644 index 0000000..03e214b --- /dev/null +++ b/packages/js-sdk/tests/cmdHelper.ts @@ -0,0 +1,20 @@ +import { CommandHandle, CommandExitError } from '../src/index.js' +import { assert } from 'vitest' + +export function catchCmdExitErrorInBackground(cmd: CommandHandle) { + let disabled = false + + cmd.wait().catch((res: CommandExitError) => { + if (!disabled) { + assert.equal( + res.exitCode, + 0, + `command failed with exit code ${res.exitCode}: ${res.stderr}` + ) + } + }) + + return () => { + disabled = true + } +} diff --git a/packages/js-sdk/tests/connectionConfig.browser.test.ts b/packages/js-sdk/tests/connectionConfig.browser.test.ts new file mode 100644 index 0000000..12cc3fa --- /dev/null +++ b/packages/js-sdk/tests/connectionConfig.browser.test.ts @@ -0,0 +1,24 @@ +import { afterEach, assert, test, vi } from 'vitest' + +afterEach(() => { + vi.resetModules() + vi.doUnmock('../src/utils') +}) + +test('sandbox_url keeps per-sandbox host in browser runtime', async () => { + vi.doMock('../src/utils', () => ({ + runtime: 'browser', + runtimeVersion: 'test', + })) + + const { ConnectionConfig } = await import('../src/connectionConfig') + const config = new ConnectionConfig() + + assert.equal( + config.getSandboxUrl('sbx-test', { + sandboxDomain: 'e2b.app', + envdPort: 49983, + }), + 'https://49983-sbx-test.e2b.app' + ) +}) diff --git a/packages/js-sdk/tests/connectionConfig.test.ts b/packages/js-sdk/tests/connectionConfig.test.ts new file mode 100644 index 0000000..6a839ff --- /dev/null +++ b/packages/js-sdk/tests/connectionConfig.test.ts @@ -0,0 +1,568 @@ +import { assert, test, beforeEach, afterEach } from 'vitest' +import { + ConnectionConfig, + setupRequestController, + wrapStreamWithConnectionCleanup, +} from '../src/connectionConfig' + +// Store original env vars to restore after tests +let originalEnv: { [key: string]: string | undefined } + +beforeEach(() => { + originalEnv = { + E2B_API_URL: process.env.E2B_API_URL, + E2B_DOMAIN: process.env.E2B_DOMAIN, + E2B_SANDBOX_URL: process.env.E2B_SANDBOX_URL, + E2B_DEBUG: process.env.E2B_DEBUG, + E2B_VALIDATE_API_KEY: process.env.E2B_VALIDATE_API_KEY, + } +}) + +afterEach(() => { + // Restore original env vars + Object.keys(originalEnv).forEach((key) => { + if (originalEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = originalEnv[key] + } + }) + + // Clear the process-wide integration attribution + ConnectionConfig.setIntegration(undefined) +}) + +test('api_url defaults correctly', () => { + // Ensure no env vars interfere + delete process.env.E2B_API_URL + delete process.env.E2B_DOMAIN + delete process.env.E2B_DEBUG + + const config = new ConnectionConfig() + assert.equal(config.apiUrl, 'https://api.e2b.app') +}) + +test('api_url in args', () => { + const config = new ConnectionConfig({ apiUrl: 'http://localhost:8080' }) + assert.equal(config.apiUrl, 'http://localhost:8080') +}) + +test('api_url in env var', () => { + process.env.E2B_API_URL = 'http://localhost:8080' + + const config = new ConnectionConfig() + assert.equal(config.apiUrl, 'http://localhost:8080') +}) + +test('api_url has correct priority', () => { + process.env.E2B_API_URL = 'http://localhost:1111' + + const config = new ConnectionConfig({ apiUrl: 'http://localhost:8080' }) + assert.equal(config.apiUrl, 'http://localhost:8080') +}) + +test('sandbox_url defaults to stable sandbox host in production', () => { + delete process.env.E2B_SANDBOX_URL + delete process.env.E2B_DOMAIN + delete process.env.E2B_DEBUG + + const config = new ConnectionConfig() + + assert.equal( + config.getSandboxUrl('sbx-test', { + sandboxDomain: 'e2b.app', + envdPort: 49983, + }), + 'https://sandbox.e2b.app' + ) +}) + +test('sandbox_direct_url keeps per-sandbox host in production', () => { + delete process.env.E2B_SANDBOX_URL + delete process.env.E2B_DOMAIN + delete process.env.E2B_DEBUG + + const config = new ConnectionConfig() + + assert.equal( + config.getSandboxDirectUrl('sbx-test', { + sandboxDomain: 'e2b.app', + envdPort: 49983, + }), + 'https://49983-sbx-test.e2b.app' + ) +}) + +test('sandbox_url keeps per-sandbox host outside production', () => { + delete process.env.E2B_SANDBOX_URL + delete process.env.E2B_DEBUG + + const config = new ConnectionConfig({ domain: 'e2b.dev' }) + + assert.equal( + config.getSandboxUrl('sbx-test', { + sandboxDomain: 'sandbox.e2b.dev', + envdPort: 49983, + }), + 'https://49983-sbx-test.sandbox.e2b.dev' + ) +}) + +test('sandbox_url in args has priority', () => { + process.env.E2B_SANDBOX_URL = 'https://sandbox.from-env' + + const config = new ConnectionConfig({ sandboxUrl: 'https://sandbox.custom' }) + + assert.equal( + config.getSandboxUrl('sbx-test', { + sandboxDomain: 'e2b.app', + envdPort: 49983, + }), + 'https://sandbox.custom' + ) +}) + +test('sandbox_url in env var overrides default', () => { + process.env.E2B_SANDBOX_URL = 'https://sandbox.from-env' + + const config = new ConnectionConfig() + + assert.equal( + config.getSandboxUrl('sbx-test', { + sandboxDomain: 'e2b.app', + envdPort: 49983, + }), + 'https://sandbox.from-env' + ) +}) + +test('sandbox_url stays localhost in debug mode', () => { + delete process.env.E2B_SANDBOX_URL + process.env.E2B_DEBUG = 'true' + + const config = new ConnectionConfig() + + assert.equal( + config.getSandboxUrl('sbx-test', { + sandboxDomain: 'e2b.app', + envdPort: 49983, + }), + 'http://localhost:49983' + ) +}) + +test('validateApiKey defaults to true', () => { + delete process.env.E2B_VALIDATE_API_KEY + + const config = new ConnectionConfig() + assert.equal(config.validateApiKey, true) +}) + +test('validateApiKey disabled via env var', () => { + process.env.E2B_VALIDATE_API_KEY = 'false' + + const config = new ConnectionConfig() + assert.equal(config.validateApiKey, false) +}) + +test('validateApiKey in args has priority over env var', () => { + process.env.E2B_VALIDATE_API_KEY = 'true' + + const config = new ConnectionConfig({ validateApiKey: false }) + assert.equal(config.validateApiKey, false) +}) + +test('debug false in args overrides E2B_DEBUG env var', () => { + process.env.E2B_DEBUG = 'true' + + const config = new ConnectionConfig({ debug: false }) + assert.equal(config.debug, false) +}) + +test('debug defaults to E2B_DEBUG env var', () => { + process.env.E2B_DEBUG = 'true' + + const config = new ConnectionConfig() + assert.equal(config.debug, true) +}) + +test('setIntegration appends the integration to the user agent', () => { + ConnectionConfig.setIntegration('testing/version') + const config = new ConnectionConfig() + + assert.equal(config.headers?.['User-Agent']?.startsWith('e2b-js-sdk/'), true) + assert.equal( + config.headers?.['User-Agent']?.endsWith(' testing/version'), + true + ) +}) + +test('integration survives config rebuilds', () => { + ConnectionConfig.setIntegration('testing/version') + const config = new ConnectionConfig() + const rebuiltConfig = new ConnectionConfig({ ...config }) + + assert.equal( + rebuiltConfig.headers?.['User-Agent']?.endsWith(' testing/version'), + true + ) +}) + +test('setIntegration does not retro-tag configs built earlier', () => { + const before = new ConnectionConfig() + ConnectionConfig.setIntegration('testing/version') + const after = new ConnectionConfig() + + assert.equal(before.headers?.['User-Agent']?.includes('testing'), false) + assert.equal( + after.headers?.['User-Agent']?.endsWith(' testing/version'), + true + ) +}) + +test('clearing the integration restores the plain user agent', () => { + ConnectionConfig.setIntegration('testing/version') + ConnectionConfig.setIntegration(undefined) + const config = new ConnectionConfig() + + assert.equal(config.headers?.['User-Agent']?.startsWith('e2b-js-sdk/'), true) + assert.equal(config.headers?.['User-Agent']?.includes('testing'), false) +}) + +test('custom user agent is preserved without integration', () => { + const config = new ConnectionConfig({ + apiHeaders: { 'User-Agent': 'my-app/1.0' }, + }) + + assert.equal(config.headers?.['User-Agent'], 'my-app/1.0') +}) + +test('custom user agent wins over integration', () => { + ConnectionConfig.setIntegration('testing/version') + + const config = new ConnectionConfig({ + headers: { 'User-Agent': 'my-app/1.0' }, + }) + + assert.equal(config.headers?.['User-Agent'], 'my-app/1.0') +}) + +test('custom user agent survives config rebuilds', () => { + ConnectionConfig.setIntegration('testing/version') + const config = new ConnectionConfig({ + apiHeaders: { 'User-Agent': 'my-app/1.0' }, + }) + const rebuiltConfig = new ConnectionConfig({ ...config }) + + assert.equal(rebuiltConfig.headers?.['User-Agent'], 'my-app/1.0') +}) + +test('clearing the integration propagates to config rebuilds', () => { + ConnectionConfig.setIntegration('testing/version') + const config = new ConnectionConfig() + ConnectionConfig.setIntegration(undefined) + const rebuiltConfig = new ConnectionConfig({ ...config }) + + assert.equal( + rebuiltConfig.headers?.['User-Agent']?.startsWith('e2b-js-sdk/'), + true + ) + assert.equal( + rebuiltConfig.headers?.['User-Agent']?.includes('testing'), + false + ) +}) + +test('getSignal returns user signal when no timeout is set', () => { + const config = new ConnectionConfig({ requestTimeoutMs: 0 }) + const controller = new AbortController() + const signal = config.getSignal(0, controller.signal) + assert.strictEqual(signal, controller.signal) +}) + +test('getSignal aborts when user signal is aborted', () => { + const config = new ConnectionConfig({ requestTimeoutMs: 60_000 }) + const controller = new AbortController() + const signal = config.getSignal(undefined, controller.signal) + assert.ok(signal) + assert.equal(signal!.aborted, false) + controller.abort() + assert.equal(signal!.aborted, true) +}) + +test('getSignal returns timeout signal when no user signal is provided', () => { + const config = new ConnectionConfig({ requestTimeoutMs: 60_000 }) + const signal = config.getSignal() + assert.ok(signal) + assert.equal(signal!.aborted, false) +}) + +test('getSignal returns undefined when no timeout and no signal', () => { + const config = new ConnectionConfig({ requestTimeoutMs: 0 }) + const signal = config.getSignal(0) + assert.equal(signal, undefined) +}) + +test('requestTimeoutMs 0 from the config disables the timeout', () => { + const config = new ConnectionConfig({ requestTimeoutMs: 0 }) + // The stored value is kept as 0 (not replaced by the default). + assert.equal(config.requestTimeoutMs, 0) + // getSignal() with no per-call arg falls back to the stored 0, which must + // NOT produce a timeout signal. + assert.equal(config.getSignal(), undefined) + // With only a user signal, no timeout signal is layered on top. + const controller = new AbortController() + assert.strictEqual( + config.getSignal(undefined, controller.signal), + controller.signal + ) +}) + +test('setupRequestController with config timeout 0 never auto-aborts', async () => { + const config = new ConnectionConfig({ requestTimeoutMs: 0 }) + const { controller } = setupRequestController( + config.requestTimeoutMs, + undefined + ) + await new Promise((resolve) => setTimeout(resolve, 40)) + assert.equal(controller.signal.aborted, false) +}) + +test('setupRequestController aborts when user signal aborts', () => { + const userController = new AbortController() + const { controller } = setupRequestController(0, userController.signal) + + assert.equal(controller.signal.aborted, false) + userController.abort() + assert.equal(controller.signal.aborted, true) +}) + +test('setupRequestController is already aborted when user signal was pre-aborted', () => { + const userController = new AbortController() + userController.abort() + + const { controller } = setupRequestController(0, userController.signal) + assert.equal(controller.signal.aborted, true) +}) + +test('setupRequestController cleanup removes the user-signal listener', () => { + const userController = new AbortController() + const { controller, cleanup } = setupRequestController( + 0, + userController.signal + ) + + cleanup() + // Internal controller is aborted by cleanup, so it stays aborted. + assert.equal(controller.signal.aborted, true) + + // After cleanup the listener is detached — a subsequent abort on the + // user signal must not propagate (verified indirectly: cleanup is + // idempotent and no error is thrown). + userController.abort() + cleanup() +}) + +test('setupRequestController clearStartTimeout stops the handshake timer', async () => { + const { controller, clearStartTimeout } = setupRequestController( + 20, + undefined + ) + clearStartTimeout() + await new Promise((resolve) => setTimeout(resolve, 40)) + assert.equal(controller.signal.aborted, false) +}) + +test('setupRequestController handshake timer aborts when not cleared', async () => { + const { controller } = setupRequestController(20, undefined) + await new Promise((resolve) => setTimeout(resolve, 40)) + assert.equal(controller.signal.aborted, true) +}) + +test('setupRequestController handshake timeout aborts with TimeoutError reason', async () => { + const { controller } = setupRequestController(20, undefined) + await new Promise((resolve) => setTimeout(resolve, 40)) + assert.equal(controller.signal.aborted, true) + assert.ok(controller.signal.reason instanceof DOMException) + assert.equal((controller.signal.reason as DOMException).name, 'TimeoutError') +}) + +test('setupRequestController user signal still cancels after clearStartTimeout', () => { + const userController = new AbortController() + const { controller, clearStartTimeout } = setupRequestController( + 60_000, + userController.signal + ) + clearStartTimeout() + assert.equal(controller.signal.aborted, false) + userController.abort() + assert.equal(controller.signal.aborted, true) +}) + +// Builds a source ReadableStream that records whether its underlying reader was +// cancelled, standing in for a fetch response body backed by a pooled +// connection. `cancel` being invoked is what releases that connection. +function trackedSource() { + const state: { cancelled: boolean; cancelReason: unknown } = { + cancelled: false, + cancelReason: undefined, + } + const chunks = ['a', 'b'].map((s) => new TextEncoder().encode(s)) + let i = 0 + const body = new ReadableStream({ + pull(controller) { + if (i < chunks.length) { + controller.enqueue(chunks[i++]) + } else { + controller.close() + } + }, + cancel(reason) { + state.cancelled = true + state.cancelReason = reason + }, + }) + return { body, state } +} + +async function readAll(stream: ReadableStream): Promise { + const reader = stream.getReader() + let out = '' + const decoder = new TextDecoder() + for (;;) { + const { done, value } = await reader.read() + if (done) break + out += decoder.decode(value, { stream: true }) + } + return out +} + +// Builds a source ReadableStream that never produces a chunk and errors its +// pending read when `signal` aborts, standing in for a stalled fetch response +// body whose connection is torn down by aborting the request controller. +function stallingSource(signal: AbortSignal) { + const state: { cancelled: boolean } = { cancelled: false } + const body = new ReadableStream({ + start(controller) { + signal.addEventListener('abort', () => controller.error(signal.reason), { + once: true, + }) + }, + cancel() { + state.cancelled = true + }, + }) + return { body, state } +} + +test('wrapStreamWithConnectionCleanup releases once on full read', async () => { + const { body } = trackedSource() + let cleanups = 0 + const stream = wrapStreamWithConnectionCleanup(body, { + clearStartTimeout: () => {}, + cleanup: () => { + cleanups++ + }, + controller: new AbortController(), + }) + assert.equal(await readAll(stream), 'ab') + assert.equal(cleanups, 1) +}) + +test('wrapStreamWithConnectionCleanup cancel cancels the underlying reader', async () => { + const { body, state } = trackedSource() + let cleanups = 0 + const stream = wrapStreamWithConnectionCleanup(body, { + clearStartTimeout: () => {}, + cleanup: () => { + cleanups++ + }, + controller: new AbortController(), + }) + await stream.cancel('done') + assert.equal(state.cancelled, true) + assert.equal(state.cancelReason, 'done') + assert.equal(cleanups, 1) +}) + +test('wrapStreamWithConnectionCleanup handles a null body', async () => { + let cleanups = 0 + let cleared = 0 + const stream = wrapStreamWithConnectionCleanup(null, { + clearStartTimeout: () => { + cleared++ + }, + cleanup: () => { + cleanups++ + }, + controller: new AbortController(), + }) + assert.equal(cleared, 1) + assert.equal(cleanups, 1) + assert.equal(await readAll(stream), '') +}) + +test('wrapStreamWithConnectionCleanup aborts and releases an idle stream', async () => { + const controller = new AbortController() + const { body } = stallingSource(controller.signal) + let cleanups = 0 + const stream = wrapStreamWithConnectionCleanup(body, { + clearStartTimeout: () => {}, + cleanup: () => { + cleanups++ + }, + controller, + idleTimeoutMs: 20, + }) + + // No chunk ever arrives, so the idle timer fires, aborts the controller, + // and the read rejects with the TimeoutError reason. + let error: unknown + try { + await readAll(stream) + } catch (err) { + error = err + } + assert.equal((error as DOMException)?.name, 'TimeoutError') + assert.equal(cleanups, 1) + assert.equal(controller.signal.aborted, true) +}) + +test('wrapStreamWithConnectionCleanup with idle timeout 0 never auto-aborts', async () => { + const { body } = trackedSource() + let cleanups = 0 + const stream = wrapStreamWithConnectionCleanup(body, { + clearStartTimeout: () => {}, + cleanup: () => { + cleanups++ + }, + controller: new AbortController(), + idleTimeoutMs: 0, + }) + assert.equal(await readAll(stream), 'ab') + assert.equal(cleanups, 1) +}) + +test('wrapStreamWithConnectionCleanup does not abort a slow consumer (wire-only)', async () => { + // Source has both chunks ready immediately; the consumer pauses far longer + // than the idle timeout between reads. Because the timer is armed only around + // the network read and cleared as soon as a chunk arrives, the consumer's + // pace must not trip it. + const controller = new AbortController() + const { body } = trackedSource() + const stream = wrapStreamWithConnectionCleanup(body, { + clearStartTimeout: () => {}, + cleanup: () => {}, + controller, + idleTimeoutMs: 20, + }) + const reader = stream.getReader() + const decoder = new TextDecoder() + + const first = await reader.read() + assert.equal(decoder.decode(first.value), 'a') + await new Promise((resolve) => setTimeout(resolve, 50)) + const second = await reader.read() + assert.equal(decoder.decode(second.value), 'b') + assert.equal(controller.signal.aborted, false) +}) diff --git a/packages/js-sdk/tests/envd/handleEnvdApiError.test.ts b/packages/js-sdk/tests/envd/handleEnvdApiError.test.ts new file mode 100644 index 0000000..d446cef --- /dev/null +++ b/packages/js-sdk/tests/envd/handleEnvdApiError.test.ts @@ -0,0 +1,152 @@ +import { assert, test, describe } from 'vitest' +import { handleEnvdApiError, handleEnvdApiFetchError } from '../../src/envd/api' +import { + AuthenticationError, + InvalidArgumentError, + NotEnoughSpaceError, + NotFoundError, + RateLimitError, + SandboxError, + TimeoutError, +} from '../../src/errors' + +function createMockResponse( + status: number, + error?: { message?: string } | string +): { + error?: { message?: string } | string + response: Response +} { + return { + error, + response: { + status, + ok: status >= 200 && status < 300, + statusText: '', + // openapi-fetch consumes the body whenever it produces an error value + bodyUsed: error !== undefined, + text: async () => (typeof error === 'string' ? error : ''), + } as unknown as Response, + } +} + +describe('handleEnvdApiError', () => { + test('returns undefined for a successful response', async () => { + const err = await handleEnvdApiError(createMockResponse(200)) + assert.isUndefined(err) + }) + + test('returns an error for non-2xx response without content', async () => { + // openapi-fetch leaves `error` undefined for responses with + // Content-Length: 0 + const res = createMockResponse(500) + const err = await handleEnvdApiError(res) + assert.instanceOf(err, SandboxError) + assert.include(err?.message, '500') + }) + + test('returns an error for non-2xx response with empty string error', async () => { + const res = createMockResponse(500, '') + const err = await handleEnvdApiError(res) + assert.instanceOf(err, SandboxError) + assert.include(err?.message, '500') + }) + + test('returns a mapped error for non-2xx response without content', async () => { + const res = createMockResponse(404) + const err = await handleEnvdApiError(res) + assert.instanceOf(err, NotFoundError) + }) + + test('returns InvalidArgumentError for 400', async () => { + const res = createMockResponse(400, { message: 'Bad request' }) + const err = await handleEnvdApiError(res) + assert.instanceOf(err, InvalidArgumentError) + }) + + test('returns AuthenticationError for 401', async () => { + const res = createMockResponse(401, { message: 'Invalid token' }) + const err = await handleEnvdApiError(res) + assert.instanceOf(err, AuthenticationError) + }) + + test('returns NotFoundError for 404', async () => { + const res = createMockResponse(404, { message: 'Not found' }) + const err = await handleEnvdApiError(res) + assert.instanceOf(err, NotFoundError) + }) + + test('returns RateLimitError for 429', async () => { + const res = createMockResponse(429, { message: 'Too many requests' }) + const err = await handleEnvdApiError(res) + assert.instanceOf(err, RateLimitError) + assert.include(err?.message, 'rate limited') + }) + + test('returns TimeoutError for 502', async () => { + const res = createMockResponse(502, { message: 'Bad gateway' }) + const err = await handleEnvdApiError(res) + assert.instanceOf(err, TimeoutError) + }) + + test('returns NotEnoughSpaceError for 507', async () => { + const res = createMockResponse(507, { message: 'No space left' }) + const err = await handleEnvdApiError(res) + assert.instanceOf(err, NotEnoughSpaceError) + }) + + test('falls back to SandboxError for unmapped status', async () => { + const res = createMockResponse(500, { message: 'Internal error' }) + const err = await handleEnvdApiError(res) + assert.instanceOf(err, SandboxError) + assert.include(err?.message, '500') + }) +}) + +describe('handleEnvdApiFetchError', () => { + test('returns the original error for terminated fetch without a health check', async () => { + const original = new TypeError('terminated') + const err = await handleEnvdApiFetchError(original) + assert.strictEqual(err, original) + }) + + test('returns a TimeoutError when the health check says the sandbox is not running', async () => { + const err = await handleEnvdApiFetchError( + new TypeError('terminated'), + async () => false + ) + assert.instanceOf(err, TimeoutError) + assert.include(err.message, 'sandbox was killed or reached its end of life') + }) + + // Each JS runtime surfaces a dropped connection with different wording, and not + // always as a TypeError (Bun raises a plain Error), so match by message + const runtimeTerminatedErrors = { + Node: new TypeError('terminated'), + Bun: new Error('The socket connection was closed unexpectedly'), + Deno: new TypeError('error reading a body from connection'), + } + + for (const [runtime, error] of Object.entries(runtimeTerminatedErrors)) { + test(`treats the ${runtime} dropped-connection error as terminated`, async () => { + const err = await handleEnvdApiFetchError(error, async () => false) + assert.instanceOf(err, TimeoutError) + assert.include( + err.message, + 'sandbox was killed or reached its end of life' + ) + }) + } + + test('returns the original error when the health check says the sandbox is running', async () => { + const original = new TypeError('terminated') + const err = await handleEnvdApiFetchError(original, async () => true) + assert.strictEqual(err, original) + }) + + test('returns the original error for other fetch failures', async () => { + const original = new TypeError('fetch failed') + const err = await handleEnvdApiFetchError(original) + assert.strictEqual(err, original) + }) +}) diff --git a/packages/js-sdk/tests/envd/handleRpcError.test.ts b/packages/js-sdk/tests/envd/handleRpcError.test.ts new file mode 100644 index 0000000..c3d0a36 --- /dev/null +++ b/packages/js-sdk/tests/envd/handleRpcError.test.ts @@ -0,0 +1,138 @@ +import { assert, test, describe } from 'vitest' +import { Code, ConnectError } from '@connectrpc/connect' +import { + handleRpcError, + handleRpcErrorWithHealthCheck, +} from '../../src/envd/rpc' +import { + AuthenticationError, + InvalidArgumentError, + NotFoundError, + RateLimitError, + SandboxError, + TimeoutError, +} from '../../src/errors' + +describe('handleRpcError', () => { + test('returns InvalidArgumentError for InvalidArgument', () => { + const err = handleRpcError(new ConnectError('bad', Code.InvalidArgument)) + assert.instanceOf(err, InvalidArgumentError) + }) + + test('returns AuthenticationError for Unauthenticated', () => { + const err = handleRpcError(new ConnectError('nope', Code.Unauthenticated)) + assert.instanceOf(err, AuthenticationError) + }) + + test('returns NotFoundError for NotFound', () => { + const err = handleRpcError(new ConnectError('missing', Code.NotFound)) + assert.instanceOf(err, NotFoundError) + }) + + test('returns RateLimitError for ResourceExhausted', () => { + const err = handleRpcError( + new ConnectError('too many', Code.ResourceExhausted) + ) + assert.instanceOf(err, RateLimitError) + assert.include(err.message, 'Rate limit') + }) + + test('returns TimeoutError for Unavailable', () => { + const err = handleRpcError(new ConnectError('gone', Code.Unavailable)) + assert.instanceOf(err, TimeoutError) + }) + + test('falls back to SandboxError for unmapped code', () => { + const err = handleRpcError(new ConnectError('boom', Code.Internal)) + assert.instanceOf(err, SandboxError) + }) + + test('falls back to generic SandboxError for Unknown "terminated"', () => { + const err = handleRpcError(new ConnectError('terminated', Code.Unknown)) + assert.instanceOf(err, SandboxError) + assert.include(err.message, 'terminated') + assert.notInclude(err.message, 'killed') + }) + + test('returns the original error when not a ConnectError', () => { + const original = new Error('not connect') + const err = handleRpcError(original) + assert.strictEqual(err, original) + }) +}) + +describe('handleRpcErrorWithHealthCheck', () => { + const terminated = () => new ConnectError('terminated', Code.Unknown) + + // Each JS runtime surfaces a dropped connection with different wording + const runtimeTerminatedMessages = { + Node: 'terminated', + Bun: 'The socket connection was closed unexpectedly', + Deno: 'error reading a body from connection', + } + + test('returns a TimeoutError when the health check says the sandbox is not running', async () => { + const err = await handleRpcErrorWithHealthCheck( + terminated(), + async () => false + ) + assert.instanceOf(err, TimeoutError) + assert.include(err.message, 'sandbox was killed or reached its end of life') + }) + + for (const [runtime, message] of Object.entries(runtimeTerminatedMessages)) { + test(`treats the ${runtime} dropped-connection message as terminated`, async () => { + const err = await handleRpcErrorWithHealthCheck( + new ConnectError(message, Code.Unknown), + async () => false + ) + assert.instanceOf(err, TimeoutError) + assert.include( + err.message, + 'sandbox was killed or reached its end of life' + ) + }) + } + + test('falls back to the generic mapping when the health check says the sandbox is running', async () => { + const err = await handleRpcErrorWithHealthCheck( + terminated(), + async () => true + ) + assert.instanceOf(err, SandboxError) + assert.notInstanceOf(err, TimeoutError) + assert.notInclude(err.message, 'killed') + }) + + test('falls back to the generic mapping when the sandbox state is unknown', async () => { + const err = await handleRpcErrorWithHealthCheck( + terminated(), + async () => undefined + ) + assert.instanceOf(err, SandboxError) + assert.notInstanceOf(err, TimeoutError) + assert.notInclude(err.message, 'killed') + }) + + test('falls back to the generic mapping when the health check itself fails', async () => { + const err = await handleRpcErrorWithHealthCheck(terminated(), async () => { + throw new Error('health check failed') + }) + assert.instanceOf(err, SandboxError) + assert.notInstanceOf(err, TimeoutError) + assert.notInclude(err.message, 'killed') + }) + + test('does not run the health check for other errors', async () => { + let called = false + const err = await handleRpcErrorWithHealthCheck( + new ConnectError('missing', Code.NotFound), + async () => { + called = true + return false + } + ) + assert.instanceOf(err, NotFoundError) + assert.isFalse(called) + }) +}) diff --git a/packages/js-sdk/tests/envd/http2.test.ts b/packages/js-sdk/tests/envd/http2.test.ts new file mode 100644 index 0000000..01ca903 --- /dev/null +++ b/packages/js-sdk/tests/envd/http2.test.ts @@ -0,0 +1,276 @@ +import { afterEach, expect, test, vi } from 'vitest' + +afterEach(() => { + vi.restoreAllMocks() + vi.resetModules() + vi.doUnmock('undici') + vi.doUnmock('../../src/utils') + delete process.env.E2B_ENVD_RPC_CONNECTIONS + delete process.env.E2B_ENVD_INFLIGHT_REQUESTS + delete process.env.E2B_ENVD_RPC_INFLIGHT_REQUESTS +}) + +test('uses undici with HTTP/2 enabled in Node', async () => { + const agents: Array<{ allowH2?: boolean; connections?: number }> = [] + const requests: Array<{ init?: RequestInit & { dispatcher?: unknown } }> = [] + + class Agent { + constructor(options: { allowH2?: boolean; connections?: number }) { + agents.push(options) + } + } + + const undiciFetch = vi.fn((input, init) => { + requests.push({ init }) + return Promise.resolve(new Response('ok')) + }) + + const { createEnvdFetchForRuntime } = await import('../../src/envd/http2') + + const fetcher = createEnvdFetchForRuntime('node', { + connectionLimit: 1, + loadUndici: () => Promise.resolve({ Agent, fetch: undiciFetch }), + }) + const res = await fetcher('https://example.com/status') + + expect(await res.text()).toBe('ok') + expect(agents).toEqual([{ allowH2: true, connections: 1 }]) + expect(requests[0].init?.dispatcher).toBeInstanceOf(Agent) +}) + +test('uses a ProxyAgent dispatcher when a proxy is configured', async () => { + const proxyAgents: Array<{ + uri?: string + allowH2?: boolean + connections?: number + }> = [] + const agents: Array = [] + const requests: Array<{ init?: RequestInit & { dispatcher?: unknown } }> = [] + + class Agent { + constructor() { + agents.push(this) + } + } + + class ProxyAgent { + constructor(options: { + uri?: string + allowH2?: boolean + connections?: number + }) { + proxyAgents.push(options) + } + } + + const undiciFetch = vi.fn((input, init) => { + requests.push({ init }) + return Promise.resolve(new Response('ok')) + }) + + const { createEnvdFetchForRuntime } = await import('../../src/envd/http2') + + const fetcher = createEnvdFetchForRuntime('node', { + connectionLimit: 1, + proxy: 'http://127.0.0.1:8080', + loadUndici: () => + Promise.resolve({ Agent, ProxyAgent, fetch: undiciFetch }), + }) + await fetcher('https://example.com/status') + + expect(agents).toHaveLength(0) + expect(proxyAgents).toEqual([ + { uri: 'http://127.0.0.1:8080', allowH2: true, connections: 1 }, + ]) + expect(requests[0].init?.dispatcher).toBeInstanceOf(ProxyAgent) +}) + +test('caches envd fetchers per proxy', async () => { + const { createEnvdFetch, createEnvdRpcFetch } = await import( + '../../src/envd/http2' + ) + + const noProxy = createEnvdFetch() + const proxyA = createEnvdFetch('http://127.0.0.1:8080') + + expect(createEnvdFetch()).toBe(noProxy) + expect(createEnvdFetch('http://127.0.0.1:8080')).toBe(proxyA) + expect(proxyA).not.toBe(noProxy) + + const rpcNoProxy = createEnvdRpcFetch() + const rpcProxyA = createEnvdRpcFetch('http://127.0.0.1:8080') + + expect(createEnvdRpcFetch()).toBe(rpcNoProxy) + expect(createEnvdRpcFetch('http://127.0.0.1:8080')).toBe(rpcProxyA) + expect(rpcProxyA).not.toBe(rpcNoProxy) +}) + +test('passes Request objects to undici as URL plus init', async () => { + const requests: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [] + + class Agent {} + + const undiciFetch = vi.fn((input, init) => { + requests.push({ input, init }) + return Promise.resolve(new Response('ok')) + }) + + const { createEnvdFetchForRuntime } = await import('../../src/envd/http2') + + const fetcher = createEnvdFetchForRuntime('node', { + connectionLimit: 1, + loadUndici: () => Promise.resolve({ Agent, fetch: undiciFetch }), + }) + const body = JSON.stringify({ ok: true }) + await fetcher( + new Request('https://example.com/rpc', { + body, + headers: { 'content-type': 'application/json' }, + method: 'POST', + }) + ) + + expect(requests[0].input).toBe('https://example.com/rpc') + expect(requests[0].init?.method).toBe('POST') + expect(requests[0].init?.headers).toBeInstanceOf(Headers) + expect(requests[0].init?.body).toBeInstanceOf(ReadableStream) +}) + +test('can create a bounded dispatcher for RPC streams', async () => { + const agents: Array<{ allowH2?: boolean; connections?: number }> = [] + + class Agent { + constructor(options: { allowH2?: boolean; connections?: number }) { + agents.push(options) + } + } + + const undiciFetch = vi.fn(() => Promise.resolve(new Response('ok'))) + + const { createEnvdFetchForRuntime } = await import('../../src/envd/http2') + + const fetcher = createEnvdFetchForRuntime('node', { + connectionLimit: 100, + loadUndici: () => Promise.resolve({ Agent, fetch: undiciFetch }), + }) + await fetcher('https://example.com/rpc') + + expect(agents).toEqual([{ allowH2: true, connections: 100 }]) +}) + +test('reads RPC stream dispatcher connection limit from env', async () => { + process.env.E2B_ENVD_RPC_CONNECTIONS = '200' + + const { getEnvdRpcConnectionLimit } = await import('../../src/envd/http2') + + expect(getEnvdRpcConnectionLimit()).toBe(200) +}) + +test('getEnvdRpcConnectionLimit throws on malformed env value', async () => { + process.env.E2B_ENVD_RPC_CONNECTIONS = 'bogus' + + const { getEnvdRpcConnectionLimit } = await import('../../src/envd/http2') + + expect(() => getEnvdRpcConnectionLimit()).toThrow(/E2B_ENVD_RPC_CONNECTIONS/) +}) + +test('getEnvdInflightLimit throws on malformed env value', async () => { + process.env.E2B_ENVD_INFLIGHT_REQUESTS = 'bogus' + + const { getEnvdInflightLimit } = await import('../../src/envd/http2') + + expect(() => getEnvdInflightLimit()).toThrow(/E2B_ENVD_INFLIGHT_REQUESTS/) +}) + +test('getEnvdRpcInflightLimit throws on malformed env value', async () => { + process.env.E2B_ENVD_RPC_INFLIGHT_REQUESTS = 'bogus' + + const { getEnvdRpcInflightLimit } = await import('../../src/envd/http2') + + expect(() => getEnvdRpcInflightLimit()).toThrow( + /E2B_ENVD_RPC_INFLIGHT_REQUESTS/ + ) +}) + +test('inflight limit env vars return 0 when explicitly disabled', async () => { + process.env.E2B_ENVD_INFLIGHT_REQUESTS = '0' + process.env.E2B_ENVD_RPC_INFLIGHT_REQUESTS = '0' + + const { getEnvdInflightLimit, getEnvdRpcInflightLimit } = await import( + '../../src/envd/http2' + ) + + expect(getEnvdInflightLimit()).toBe(0) + expect(getEnvdRpcInflightLimit()).toBe(0) +}) + +test('getEnvdInflightLimit throws on negative env value', async () => { + process.env.E2B_ENVD_INFLIGHT_REQUESTS = '-1' + + const { getEnvdInflightLimit } = await import('../../src/envd/http2') + + expect(() => getEnvdInflightLimit()).toThrow(/E2B_ENVD_INFLIGHT_REQUESTS=-1/) +}) + +test('getEnvdRpcInflightLimit throws on negative env value', async () => { + process.env.E2B_ENVD_RPC_INFLIGHT_REQUESTS = '-5' + + const { getEnvdRpcInflightLimit } = await import('../../src/envd/http2') + + expect(() => getEnvdRpcInflightLimit()).toThrow( + /E2B_ENVD_RPC_INFLIGHT_REQUESTS=-5/ + ) +}) + +test('defers loading undici until the first Node request', async () => { + const Agent = vi.fn() + const undiciFetch = vi.fn(() => Promise.resolve(new Response('ok'))) + const loadUndici = vi.fn(() => Promise.resolve({ Agent, fetch: undiciFetch })) + + const { createEnvdFetchForRuntime } = await import('../../src/envd/http2') + + const fetcher = createEnvdFetchForRuntime('node', { + connectionLimit: 1, + loadUndici, + }) + + expect(loadUndici).not.toHaveBeenCalled() + expect(Agent).not.toHaveBeenCalled() + expect(undiciFetch).not.toHaveBeenCalled() + + await fetcher('https://example.com/status') + + expect(loadUndici).toHaveBeenCalledOnce() + expect(Agent).toHaveBeenCalledOnce() + expect(undiciFetch).toHaveBeenCalledOnce() +}) + +test('falls back to global fetch when undici cannot be loaded', async () => { + const fallbackFetch = vi.fn(() => + Promise.resolve(new Response('fallback ok')) + ) as unknown as typeof fetch + vi.stubGlobal('fetch', fallbackFetch) + + const { createEnvdFetchForRuntime } = await import('../../src/envd/http2') + + const fetcher = createEnvdFetchForRuntime('node', { + loadUndici: () => Promise.resolve(undefined), + }) + const res = await fetcher('https://example.com/status') + + expect(await res.text()).toBe('fallback ok') + expect(fallbackFetch).toHaveBeenCalledWith( + 'https://example.com/status', + undefined + ) +}) + +test('uses global fetch outside Node', async () => { + const fallbackFetch = vi.fn() as unknown as typeof fetch + vi.stubGlobal('fetch', fallbackFetch) + + const { createEnvdFetchForRuntime } = await import('../../src/envd/http2') + + expect(createEnvdFetchForRuntime('browser')).toBe(fallbackFetch) + expect(createEnvdFetchForRuntime('vercel-edge')).toBe(fallbackFetch) +}) diff --git a/packages/js-sdk/tests/integration/randomness.test.ts b/packages/js-sdk/tests/integration/randomness.test.ts new file mode 100644 index 0000000..a982eb3 --- /dev/null +++ b/packages/js-sdk/tests/integration/randomness.test.ts @@ -0,0 +1,53 @@ +import { test, assert } from 'vitest' + +import Sandbox from '../../src/index.js' +import { isIntegrationTest } from '../setup.js' + +const integrationTestTemplate = 'en716jw99aj63v1k8ugh' + +test.skipIf(!isIntegrationTest)( + 'test python random number generation in same sandbox', + async () => { + const sbx = await Sandbox.create(integrationTestTemplate, { + timeoutMs: 120, + }) + console.log('sandboxId', sbx.sandboxId) + const cmd1 = await sbx.commands.run( + 'python -c "import numpy as np; print([np.random.normal(),np.random.normal(),np.random.normal()])"' + ) + console.log('cmd1 stdout', cmd1.stdout) + + const cmd2 = await sbx.commands.run( + 'python -c "import numpy as np; print([np.random.normal(),np.random.normal(),np.random.normal()])"' + ) + console.log('cmd2 stdout', cmd2.stdout) + + assert.notEqual(cmd1.stdout, cmd2.stdout) + } +) + +test.skipIf(!isIntegrationTest)( + 'test python random number generation with same template', + async () => { + const sbx = await Sandbox.create(integrationTestTemplate, { + timeoutMs: 120, + }) + console.log('sandboxId 1', sbx.sandboxId) + const cmd1 = await sbx.commands.run( + 'python -c "import numpy as np; print([np.random.normal(),np.random.normal(),np.random.normal()])"' + ) + console.log('cmd1 stdout', cmd1.stdout) + await sbx.kill() + + const sbx2 = await Sandbox.create(integrationTestTemplate, { + timeoutMs: 120, + }) + console.log('sandboxId 2', sbx2.sandboxId) + const cmd2 = await sbx2.commands.run( + 'python -c "import numpy as np; print([np.random.normal(),np.random.normal(),np.random.normal()])"' + ) + console.log('cmd2 stdout', cmd2.stdout) + + assert.notEqual(cmd1.stdout, cmd2.stdout) + } +) diff --git a/packages/js-sdk/tests/integration/stress.test.ts b/packages/js-sdk/tests/integration/stress.test.ts new file mode 100644 index 0000000..83e4b0d --- /dev/null +++ b/packages/js-sdk/tests/integration/stress.test.ts @@ -0,0 +1,77 @@ +import { test } from 'vitest' + +import Sandbox from '../../src/index.js' +import { isIntegrationTest, wait } from '../setup.js' + +const heavyArray = new ArrayBuffer(256 * 1024 * 1024) // 256 MiB = 256 * 1024 * 1024 bytes +const view = new Uint8Array(heavyArray) +for (let i = 0; i < view.length; i++) { + view[i] = Math.floor(Math.random() * 256) +} + +const integrationTestTemplate = 'integration-test-v1' +const sandboxCount = 10 + +test.skipIf(!isIntegrationTest)( + 'stress test heavy file writes and reads', + async () => { + const promises: Array> = [] + for (let i = 0; i < sandboxCount; i++) { + promises.push( + Sandbox.create(integrationTestTemplate, { timeoutMs: 60 }) + .then((sbx) => { + console.log(sbx.sandboxId) + return sbx.files + .write('heavy-file', heavyArray) + .then(() => sbx.files.read('heavy-file')) + }) + .catch(console.error) + ) + } + await wait(10_000) + await Promise.all(promises) + } +) + +test.skipIf(!isIntegrationTest)('stress requests to nextjs app', async () => { + const hostPromises: Array> = [] + + for (let i = 0; i < sandboxCount; i++) { + hostPromises.push( + Sandbox.create(integrationTestTemplate, { timeoutMs: 60_000 }).then( + (sbx) => { + console.log('created sandbox', sbx.sandboxId) + return new Promise((resolve, reject) => { + try { + resolve(sbx.getHost(3000)) + } catch (e) { + console.error('error getting sbx host', e) + reject(e) + } + }) + } + ) + ) + } + + await wait(10_000) + const hosts = await Promise.all(hostPromises) + + const fetchPromises: Array> = [] + + for (let i = 0; i < 100; i++) { + for (const host of hosts) { + fetchPromises.push( + new Promise((resolve) => { + fetch('https://' + host) + .then((res) => { + console.log(`response for ${host}: ${res.status}`) + }) + .then(resolve) + }) + ) + } + } + + await Promise.all(fetchPromises) +}) diff --git a/packages/js-sdk/tests/integration/template/README.md b/packages/js-sdk/tests/integration/template/README.md new file mode 100644 index 0000000..7da9296 --- /dev/null +++ b/packages/js-sdk/tests/integration/template/README.md @@ -0,0 +1,5 @@ +# Integration test template + +# Build the template + +`$ e2b template build -c "cd /basic-nextjs-app/ && sudo npm run dev"` diff --git a/packages/js-sdk/tests/integration/template/e2b.Dockerfile b/packages/js-sdk/tests/integration/template/e2b.Dockerfile new file mode 100644 index 0000000..1eb06da --- /dev/null +++ b/packages/js-sdk/tests/integration/template/e2b.Dockerfile @@ -0,0 +1,11 @@ +FROM e2bdev/code-interpreter:latest + +# Install stress-ng, a tool to load and stress computer systems +RUN apt update +RUN apt install -y stress-ng + +# Create a basic Next.js app +RUN npx -y create-next-app@latest basic-nextjs-app --yes --ts --use-npm + +# Install dependencies +RUN cd basic-nextjs-app && npm install diff --git a/packages/js-sdk/tests/integration/template/e2b.toml b/packages/js-sdk/tests/integration/template/e2b.toml new file mode 100644 index 0000000..3f3fd1c --- /dev/null +++ b/packages/js-sdk/tests/integration/template/e2b.toml @@ -0,0 +1,18 @@ +# This is a config for E2B sandbox template. +# You can use template ID (2e2z80zhv34yumbrybvn) or template name (integration-test-v1) to create a sandbox: + +# Python SDK +# from e2b import Sandbox, AsyncSandbox +# sandbox = Sandbox("integration-test-v1") # Sync sandbox +# sandbox = await AsyncSandbox.create("integration-test-v1") # Async sandbox + +# JS SDK +# import { Sandbox } from 'e2b' +# const sandbox = await Sandbox.create('integration-test-v1') + +team_id = "460355b3-4f64-48f9-9a16-4442817f79f5" +memory_mb = 512 +start_cmd = "npm run dev" +dockerfile = "e2b.Dockerfile" +template_name = "integration-test-v1" +template_id = "2e2z80zhv34yumbrybvn" diff --git a/packages/js-sdk/tests/logs.test.ts b/packages/js-sdk/tests/logs.test.ts new file mode 100644 index 0000000..e22dc3d --- /dev/null +++ b/packages/js-sdk/tests/logs.test.ts @@ -0,0 +1,39 @@ +import { assert, describe, test } from 'vitest' +import { createRpcLogger } from '../src/logs' + +const req = { url: 'http://localhost:49983/process.Process/Start' } as any + +describe('createRpcLogger', () => { + test('logs unary responses containing bigint fields without throwing', async () => { + const logs: any[][] = [] + const interceptor = createRpcLogger({ info: (...args) => logs.push(args) }) + + const res = { stream: false, message: { size: 42n, name: 'file' } } as any + const result = await interceptor(async () => res)(req) + + assert.equal(result, res) + assert.deepEqual(logs[1], ['Response:', { size: '42', name: 'file' }]) + }) + + test('logs streamed messages containing bigint fields without throwing', async () => { + const logs: any[][] = [] + const interceptor = createRpcLogger({ debug: (...args) => logs.push(args) }) + + async function* stream() { + yield { offset: 9007199254740993n } + } + const res = { stream: true, message: stream() } as any + const result = (await interceptor(async () => res)(req)) as any + + const received = [] + for await (const m of result.message) { + received.push(m) + } + + assert.deepEqual(received, [{ offset: 9007199254740993n }]) + assert.deepEqual(logs[0], [ + 'Response stream:', + { offset: '9007199254740993' }, + ]) + }) +}) diff --git a/packages/js-sdk/tests/paginator.test.ts b/packages/js-sdk/tests/paginator.test.ts new file mode 100644 index 0000000..67169b6 --- /dev/null +++ b/packages/js-sdk/tests/paginator.test.ts @@ -0,0 +1,62 @@ +import { assert, expect, test } from 'vitest' + +import { Paginator } from '../src/paginator' + +// Build a fake HTTP Response carrying only the `x-next-token` header. +function fakeResponse(nextToken?: string): Response { + const headers = new Headers() + if (nextToken !== undefined) { + headers.set('x-next-token', nextToken) + } + return new Response(null, { headers }) +} + +// Minimal concrete paginator that returns canned pages and drives the shared +// base state machine via `updatePagination`. +class FakePaginator extends Paginator { + private call = 0 + + constructor( + private readonly pages: Array<{ items: string[]; nextToken?: string }> + ) { + super() + } + + async nextItems(): Promise { + if (!this.hasNext) { + throw new Error('No more items to fetch') + } + + const page = this.pages[this.call++] + this.updatePagination(fakeResponse(page.nextToken)) + return page.items + } +} + +test('paginator exposes pagination state and advances across pages', async () => { + const paginator = new FakePaginator([ + { items: ['a', 'b'], nextToken: 'tok-2' }, + { items: ['c'], nextToken: undefined }, + ]) + + assert.isTrue(paginator.hasNext) + assert.isUndefined(paginator.nextToken) + + const first = await paginator.nextItems() + assert.deepEqual(first, ['a', 'b']) + assert.isTrue(paginator.hasNext) + assert.equal(paginator.nextToken, 'tok-2') + + const second = await paginator.nextItems() + assert.deepEqual(second, ['c']) + assert.isFalse(paginator.hasNext) + assert.isUndefined(paginator.nextToken) +}) + +test('paginator throws once exhausted', async () => { + const paginator = new FakePaginator([{ items: [], nextToken: undefined }]) + await paginator.nextItems() + + assert.isFalse(paginator.hasNext) + await expect(paginator.nextItems()).rejects.toThrow('No more items to fetch') +}) diff --git a/packages/js-sdk/tests/runtimes/browser/run.test.tsx b/packages/js-sdk/tests/runtimes/browser/run.test.tsx new file mode 100644 index 0000000..dda6780 --- /dev/null +++ b/packages/js-sdk/tests/runtimes/browser/run.test.tsx @@ -0,0 +1,38 @@ +import { expect, inject, test } from 'vitest' +import { render } from 'vitest-browser-react' +import React from 'react' +import { useEffect, useState } from 'react' + +import { Sandbox } from '../../../src' +import { template } from '../../template' + +function E2BTest() { + const [text, setText] = useState() + + useEffect(() => { + const getText = async () => { + const sandbox = await Sandbox.create(template, { + apiKey: inject('E2B_API_KEY'), + domain: inject('E2B_DOMAIN'), + }) + + try { + await sandbox.commands.run('echo "Hello World" > hello.txt') + const content = await sandbox.files.read('hello.txt') + setText(content) + } finally { + await sandbox.kill() + } + } + + getText() + }, []) + + return
{text}
+} +test('browser test', async () => { + const screen = await render() + await expect + .element(screen.getByText('Hello World'), { timeout: 30_000 }) + .toBeInTheDocument() +}, 40_000) diff --git a/packages/js-sdk/tests/runtimes/bun/run.test.ts b/packages/js-sdk/tests/runtimes/bun/run.test.ts new file mode 100644 index 0000000..5c77b02 --- /dev/null +++ b/packages/js-sdk/tests/runtimes/bun/run.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from 'bun:test' + +import { Sandbox } from '../../../src' +import { template } from '../../template' + +test( + 'Bun test', + async () => { + const sbx = await Sandbox.create(template, { timeoutMs: 5_000 }) + try { + const isRunning = await sbx.isRunning() + expect(isRunning).toBeTruthy() + + const text = 'Hello, World!' + + const cmd = await sbx.commands.run(`echo "${text}"`) + + expect(cmd.exitCode).toBe(0) + expect(cmd.stdout).toEqual(`${text}\n`) + + await sbx.files.write('test.txt', text) + const test = await sbx.files.read('test.txt') + + expect(test).toEqual(text) + } finally { + await sbx.kill() + } + }, + { timeout: 20_000 } +) diff --git a/packages/js-sdk/tests/runtimes/deno/run.test.ts b/packages/js-sdk/tests/runtimes/deno/run.test.ts new file mode 100644 index 0000000..9f9d35c --- /dev/null +++ b/packages/js-sdk/tests/runtimes/deno/run.test.ts @@ -0,0 +1,27 @@ +import { + assert, + assertEquals, +} from 'https://deno.land/std@0.224.0/assert/mod.ts' +import { load } from 'https://deno.land/std@0.224.0/dotenv/mod.ts' + +await load({ envPath: '.env', export: true }) + +import { Sandbox } from '../../../dist/index.mjs' +import { template } from '../../template' + +Deno.test('Deno test', async () => { + const sbx = await Sandbox.create(template, { timeoutMs: 5_000 }) + try { + const isRunning = await sbx.isRunning() + assert(isRunning) + + const text = 'Hello, World!' + + const cmd = await sbx.commands.run(`echo "${text}"`) + + assertEquals(cmd.exitCode, 0) + assertEquals(cmd.stdout, `${text}\n`) + } finally { + await sbx.kill() + } +}) diff --git a/packages/js-sdk/tests/sandbox/abortSignal.test.ts b/packages/js-sdk/tests/sandbox/abortSignal.test.ts new file mode 100644 index 0000000..2aa767c --- /dev/null +++ b/packages/js-sdk/tests/sandbox/abortSignal.test.ts @@ -0,0 +1,116 @@ +import { afterAll, afterEach, beforeAll, expect, test } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +import { Sandbox } from '../../src' +import { TEST_API_KEY, apiUrl } from '../setup' + +// Hold the request open until the caller aborts. If the signal is already +// aborted by the time the handler runs, `addEventListener('abort', …)` would +// never fire — so check `aborted` first to avoid hanging. +function holdUntilAborted(signal: AbortSignal): Promise { + return new Promise((_, reject) => { + const abort = () => reject(new DOMException('aborted', 'AbortError')) + if (signal.aborted) { + abort() + return + } + signal.addEventListener('abort', abort, { once: true }) + }) +} + +const restHandlers = [ + http.post(apiUrl('/sandboxes'), async ({ request }) => { + await holdUntilAborted(request.signal) + return HttpResponse.json({}) + }), + http.delete(apiUrl('/sandboxes/:sandboxID'), async ({ request }) => { + await holdUntilAborted(request.signal) + return HttpResponse.json({}) + }), + http.get(apiUrl('/v2/sandboxes'), async ({ request }) => { + await holdUntilAborted(request.signal) + return HttpResponse.json([]) + }), +] + +const server = setupServer(...restHandlers) + +beforeAll(() => + server.listen({ + onUnhandledRequest: 'bypass', + }) +) + +afterAll(() => server.close()) + +afterEach(() => server.resetHandlers()) + +// Resolves once MSW has dispatched the next request, so tests can abort +// deterministically instead of guessing with `setTimeout`. +function nextRequestStart(): Promise { + return new Promise((resolve) => { + const listener = () => { + server.events.removeListener('request:start', listener) + resolve() + } + server.events.on('request:start', listener) + }) +} + +test('Sandbox.create rejects when AbortSignal is aborted', async () => { + const controller = new AbortController() + const requestStarted = nextRequestStart() + + const promise = Sandbox.create('base', { + apiKey: TEST_API_KEY, + signal: controller.signal, + }) + + await requestStarted + controller.abort() + + await expect(promise).rejects.toThrow() +}) + +test('Sandbox.create rejects immediately when signal is already aborted', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + Sandbox.create('base', { + apiKey: TEST_API_KEY, + signal: controller.signal, + }) + ).rejects.toThrow() +}) + +test('Sandbox.kill rejects when AbortSignal is aborted', async () => { + const controller = new AbortController() + const requestStarted = nextRequestStart() + + const promise = Sandbox.kill('some-sandbox', { + apiKey: TEST_API_KEY, + signal: controller.signal, + }) + + await requestStarted + controller.abort() + + await expect(promise).rejects.toThrow() +}) + +test('SandboxPaginator.nextItems rejects when per-call AbortSignal is aborted', async () => { + const controller = new AbortController() + const requestStarted = nextRequestStart() + + const paginator = Sandbox.list({ + apiKey: TEST_API_KEY, + }) + const promise = paginator.nextItems({ signal: controller.signal }) + + await requestStarted + controller.abort() + + await expect(promise).rejects.toThrow() +}) diff --git a/packages/js-sdk/tests/sandbox/commands/commandHandle.test.ts b/packages/js-sdk/tests/sandbox/commands/commandHandle.test.ts new file mode 100644 index 0000000..eec91b7 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/commands/commandHandle.test.ts @@ -0,0 +1,477 @@ +import { describe, expect, it, vi } from 'vitest' + +import { CommandHandle } from '../../../src/sandbox/commands/commandHandle' + +type EventKind = 'stdout' | 'stderr' | 'pty' + +function createEvents(kind: EventKind): AsyncIterable { + async function* events() { + if (kind === 'pty') { + yield { + event: { + event: { + case: 'data', + value: { + output: { + case: 'pty', + value: new Uint8Array([1, 2, 3]), + }, + }, + }, + }, + } + } else { + yield { + event: { + event: { + case: 'data', + value: { + output: { + case: kind, + value: new TextEncoder().encode(kind), + }, + }, + }, + }, + } + } + + yield { + event: { + event: { + case: 'end', + value: { + exitCode: 0, + error: undefined, + }, + }, + }, + } + } + + return events() +} + +function dataEvent(kind: 'stdout' | 'stderr', value: Uint8Array) { + return { + event: { + event: { + case: 'data', + value: { + output: { + case: kind, + value, + }, + }, + }, + }, + } +} + +function endEvent(exitCode = 0) { + return { + event: { + event: { + case: 'end', + value: { + exitCode, + error: undefined, + }, + }, + }, + } +} + +// An async iterable whose events are delivered on demand. Lets a test hold the +// handle's event loop blocked on `next()` (idle between bursts) and then push a +// late event to simulate stdout arriving after `disconnect()` — the transport +// condition that triggers the production leak. `return()` (called when the +// handle's `for await` breaks) unblocks any pending read, mirroring the stream +// being torn down from the client side. +function createControllableEvents() { + const queue: any[] = [] + let pending: ((result: IteratorResult) => void) | undefined + let closed = false + + const iterator: AsyncIterator = { + next() { + if (queue.length > 0) { + return Promise.resolve({ value: queue.shift(), done: false }) + } + if (closed) { + return Promise.resolve({ value: undefined, done: true }) + } + return new Promise((resolve) => { + pending = resolve + }) + }, + return() { + closed = true + if (pending) { + const resolve = pending + pending = undefined + resolve({ value: undefined, done: true }) + } + return Promise.resolve({ value: undefined, done: true }) + }, + } + + return { + events: { [Symbol.asyncIterator]: () => iterator } as AsyncIterable, + push(event: any) { + if (pending) { + const resolve = pending + pending = undefined + resolve({ value: event, done: false }) + } else { + queue.push(event) + } + }, + } +} + +describe('CommandHandle', () => { + it.each(['stdout', 'stderr', 'pty'])( + 'wait awaits async %s callbacks', + async (kind) => { + let callbackStarted = false + let releaseCallback: (() => void) | undefined + + const callbackBlocked = new Promise((resolve) => { + releaseCallback = resolve + }) + + const callback = async () => { + callbackStarted = true + await callbackBlocked + } + + const handle = new CommandHandle( + 1, + () => {}, + async () => true, + createEvents(kind), + kind === 'stdout' ? callback : undefined, + kind === 'stderr' ? callback : undefined, + kind === 'pty' ? callback : undefined + ) + + let waitResolved = false + const waitPromise = handle.wait().then(() => { + waitResolved = true + }) + + await vi.waitFor(() => { + expect(callbackStarted).toBe(true) + }) + expect(waitResolved).toBe(false) + + releaseCallback?.() + await waitPromise + + expect(waitResolved).toBe(true) + } + ) + + it('decodes multibyte characters split across chunks', async () => { + const emojiBytes = new TextEncoder().encode('😀') + + async function* events() { + yield dataEvent( + 'stdout', + new Uint8Array([ + ...new TextEncoder().encode('a'), + ...emojiBytes.slice(0, 2), + ]) + ) + yield dataEvent( + 'stdout', + new Uint8Array([ + ...emojiBytes.slice(2), + ...new TextEncoder().encode('b'), + ]) + ) + yield dataEvent('stderr', emojiBytes.slice(0, 3)) + yield dataEvent('stderr', emojiBytes.slice(3)) + yield endEvent() + } + + const stdoutChunks: string[] = [] + const handle = new CommandHandle( + 1, + () => {}, + async () => true, + events(), + (out) => { + stdoutChunks.push(out) + } + ) + + const result = await handle.wait() + + expect(result.stdout).toBe('a😀b') + expect(result.stderr).toBe('😀') + expect(result.stdout).not.toContain('�') + expect(result.stderr).not.toContain('�') + expect(stdoutChunks.join('')).toBe('a😀b') + }) + + it('replaces incomplete trailing utf-8 sequences at the end of the stream', async () => { + const emojiBytes = new TextEncoder().encode('😀') + + async function* events() { + yield dataEvent( + 'stdout', + new Uint8Array([ + ...new TextEncoder().encode('a'), + ...emojiBytes.slice(0, 2), + ]) + ) + yield endEvent() + } + + const handle = new CommandHandle( + 1, + () => {}, + async () => true, + events() + ) + + const result = await handle.wait() + + expect(result.stdout).toBe('a�') + }) + + it('flushes incomplete trailing utf-8 sequences when the stream closes without an end event', async () => { + const emojiBytes = new TextEncoder().encode('😀') + + async function* events() { + yield dataEvent( + 'stdout', + new Uint8Array([ + ...new TextEncoder().encode('a'), + ...emojiBytes.slice(0, 2), + ]) + ) + } + + const stdoutChunks: string[] = [] + const handle = new CommandHandle( + 1, + () => {}, + async () => true, + events(), + (out) => { + stdoutChunks.push(out) + } + ) + + // No end event arrives, so wait() rejects, but the buffered bytes must + // still be flushed to the stdout callback as a replacement character. + await expect(handle.wait()).rejects.toThrow() + + expect(stdoutChunks.join('')).toBe('a�') + }) + + it('flushes incomplete trailing utf-8 sequences when the stream errors', async () => { + const emojiBytes = new TextEncoder().encode('😀') + + async function* events() { + yield dataEvent( + 'stdout', + new Uint8Array([ + ...new TextEncoder().encode('a'), + ...emojiBytes.slice(0, 2), + ]) + ) + throw new Error('stream died') + } + + const stdoutChunks: string[] = [] + const handle = new CommandHandle( + 1, + () => {}, + async () => true, + events(), + (out) => { + stdoutChunks.push(out) + } + ) + + // The stream errors before an end event arrives, so wait() rejects, but the + // buffered bytes must still be flushed to the stdout callback as a + // replacement character. + await expect(handle.wait()).rejects.toThrow() + + expect(stdoutChunks.join('')).toBe('a�') + }) + + it('onStdout stops firing after await disconnect()', async () => { + const controllable = createControllableEvents() + const chunks: string[] = [] + + const handle = new CommandHandle( + 1, + () => {}, + async () => true, + controllable.events, + (out) => { + chunks.push(out) + } + ) + + // First burst is delivered to the live subscriber. + controllable.push(dataEvent('stdout', new TextEncoder().encode('a'))) + await vi.waitFor(() => { + expect(chunks).toEqual(['a']) + }) + + // Disconnect while the event loop is idle (blocked on the next read), then + // push a late event — as if stdout arrived after the abort but before the + // stream was torn down. The late event must never reach the callback. + await handle.disconnect() + controllable.push(dataEvent('stdout', new TextEncoder().encode('b'))) + await new Promise((r) => setTimeout(r, 0)) + + expect(chunks).toEqual(['a']) + + // Any further output is ignored too. + controllable.push(dataEvent('stdout', new TextEncoder().encode('c'))) + await new Promise((r) => setTimeout(r, 0)) + expect(chunks).toEqual(['a']) + }) + + it('disconnect() resolves promptly when a stream is idle', async () => { + // An idle long-running command (e.g. `sleep`) produces no further output, + // so the event handler stays blocked on the next read. disconnect() must + // not wait for that read and must resolve right away. + const controllable = createControllableEvents() + const handle = new CommandHandle( + 1, + () => {}, + async () => true, + controllable.events, + () => {} + ) + + // No event is ever pushed; this would hang if disconnect() awaited the + // event handler. + await handle.disconnect() + }) + + it('disconnect() does not block on an in-flight callback', async () => { + const controllable = createControllableEvents() + let callbackStarted = false + let releaseCallback: (() => void) | undefined + const callbackBlocked = new Promise((resolve) => { + releaseCallback = resolve + }) + + const handle = new CommandHandle( + 1, + () => {}, + async () => true, + controllable.events, + async () => { + callbackStarted = true + await callbackBlocked + } + ) + + controllable.push(dataEvent('stdout', new TextEncoder().encode('a'))) + await vi.waitFor(() => { + expect(callbackStarted).toBe(true) + }) + + // The callback is still in flight; disconnect() must resolve without + // waiting for it to settle. + await handle.disconnect() + + // Releasing the callback afterwards is harmless. + releaseCallback?.() + }) + + it('disconnect() resolves when called from inside a callback', async () => { + const controllable = createControllableEvents() + let disconnectReturned = false + + const handle: CommandHandle = new CommandHandle( + 1, + () => {}, + async () => true, + controllable.events, + async () => { + await handle.disconnect() + disconnectReturned = true + } + ) + + controllable.push(dataEvent('stdout', new TextEncoder().encode('a'))) + await vi.waitFor(() => { + expect(disconnectReturned).toBe(true) + }) + }) + + it('records the exit code when disconnected before an end event with trailing bytes', async () => { + const controllable = createControllableEvents() + const emojiBytes = new TextEncoder().encode('😀') + const chunks: string[] = [] + + const handle = new CommandHandle( + 1, + () => {}, + async () => true, + controllable.events, + (out) => { + chunks.push(out) + } + ) + + // First chunk leaves an incomplete multibyte sequence buffered in the + // decoder, so the end event will flush a trailing replacement character. + controllable.push( + dataEvent( + 'stdout', + new Uint8Array([ + ...new TextEncoder().encode('a'), + ...emojiBytes.slice(0, 2), + ]) + ) + ) + await vi.waitFor(() => { + expect(chunks).toEqual(['a']) + }) + + // Disconnect, then the process exits. The end event carries a flushed + // chunk; wait() must still surface the exit code instead of failing as if + // the process never produced a result. + await handle.disconnect() + controllable.push(endEvent(0)) + + const result = await handle.wait() + expect(result.exitCode).toBe(0) + expect(result.stdout).toBe('a�') + // The flushed chunk was produced after disconnect, so it must not reach + // the live callback. + expect(chunks).toEqual(['a']) + }) + + it('surfaces an async callback error through wait()', async () => { + async function* events() { + yield dataEvent('stdout', new TextEncoder().encode('a')) + yield endEvent() + } + + const handle = new CommandHandle( + 1, + () => {}, + async () => true, + events(), + async () => { + throw new Error('callback failed') + } + ) + + await expect(handle.wait()).rejects.toThrow('callback failed') + }) +}) diff --git a/packages/js-sdk/tests/sandbox/commands/connect.test.ts b/packages/js-sdk/tests/sandbox/commands/connect.test.ts new file mode 100644 index 0000000..a624e04 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/commands/connect.test.ts @@ -0,0 +1,18 @@ +import { assert, expect } from 'vitest' +import { sandboxTest } from '../../setup.js' + +sandboxTest('connect to process', async ({ sandbox }) => { + const cmd = await sandbox.commands.run('sleep 10', { background: true }) + const pid = cmd.pid + + const processInfo = await sandbox.commands.connect(pid) + + assert.isObject(processInfo) + assert.equal(processInfo.pid, pid) +}) + +sandboxTest('connect to non-existing process', async ({ sandbox }) => { + const nonExistingPid = 999999 + + await expect(sandbox.commands.connect(nonExistingPid)).rejects.toThrowError() +}) diff --git a/packages/js-sdk/tests/sandbox/commands/envVars.test.ts b/packages/js-sdk/tests/sandbox/commands/envVars.test.ts new file mode 100644 index 0000000..3246406 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/commands/envVars.test.ts @@ -0,0 +1,45 @@ +import { assert, describe } from 'vitest' + +import { sandboxTest, isDebug } from '../../setup.js' + +describe('sandbox global env vars', () => { + sandboxTest.scoped({ + sandboxOpts: { + envs: { FOO: 'bar' }, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'sandbox global env vars', + async ({ sandbox }) => { + const cmd = await sandbox.commands.run('echo $FOO') + + assert.equal(cmd.exitCode, 0) + assert.equal(cmd.stdout.trim(), 'bar') + } + ) +}) + +sandboxTest('bash command scoped env vars', async ({ sandbox }) => { + const cmd = await sandbox.commands.run('echo $FOO', { + envs: { FOO: 'bar' }, + }) + + assert.equal(cmd.exitCode, 0) + assert.equal(cmd.stdout.trim(), 'bar') + + // test that it is secure and not accessible to subsequent commands + const cmd2 = await sandbox.commands.run('sudo echo "$FOO"') + assert.equal(cmd2.exitCode, 0) + assert.equal(cmd2.stdout.trim(), '') +}) + +sandboxTest('python command scoped env vars', async ({ sandbox }) => { + const cmd = await sandbox.commands.run( + 'python3 -c "import os; print(os.environ[\'FOO\'])"', + { envs: { FOO: 'bar' } } + ) + + assert.equal(cmd.exitCode, 0) + assert.equal(cmd.stdout.trim(), 'bar') +}) diff --git a/packages/js-sdk/tests/sandbox/commands/kill.test.ts b/packages/js-sdk/tests/sandbox/commands/kill.test.ts new file mode 100644 index 0000000..4eddcc4 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/commands/kill.test.ts @@ -0,0 +1,21 @@ +import { expect } from 'vitest' +import { ProcessExitError } from '../../../src/index.js' + +import { sandboxTest } from '../../setup.js' + +sandboxTest('kill process', async ({ sandbox }) => { + const cmd = await sandbox.commands.run('sleep 10', { background: true }) + const pid = cmd.pid + + await sandbox.commands.kill(pid) + + await expect(sandbox.commands.run(`kill -0 ${pid}`)).rejects.toThrowError( + ProcessExitError + ) +}) + +sandboxTest('kill non-existing process', async ({ sandbox }) => { + const nonExistingPid = 999999 + + await expect(sandbox.commands.kill(nonExistingPid)).resolves.toBe(false) +}) diff --git a/packages/js-sdk/tests/sandbox/commands/list.test.ts b/packages/js-sdk/tests/sandbox/commands/list.test.ts new file mode 100644 index 0000000..24c4ced --- /dev/null +++ b/packages/js-sdk/tests/sandbox/commands/list.test.ts @@ -0,0 +1,17 @@ +import { assert } from 'vitest' +import { sandboxTest } from '../../setup.js' + +sandboxTest('list processes', async ({ sandbox }) => { + // Start them firsts + await sandbox.commands.run('sleep 10', { background: true }) + await sandbox.commands.run('sleep 10', { background: true }) + + const processes = await sandbox.commands.list() + + assert.isArray(processes) + assert.isAtLeast(processes.length, 2) + + processes.forEach((process) => { + assert.containsAllKeys(process, ['pid']) + }) +}) diff --git a/packages/js-sdk/tests/sandbox/commands/run.test.ts b/packages/js-sdk/tests/sandbox/commands/run.test.ts new file mode 100644 index 0000000..9f2e10c --- /dev/null +++ b/packages/js-sdk/tests/sandbox/commands/run.test.ts @@ -0,0 +1,54 @@ +import { expect, assert } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { TimeoutError } from '../../../src/index.js' + +sandboxTest('run', async ({ sandbox }) => { + const text = 'Hello, World!' + + const cmd = await sandbox.commands.run(`echo "${text}"`) + + assert.equal(cmd.exitCode, 0) + assert.equal(cmd.stdout, `${text}\n`) +}) + +sandboxTest('run with special characters', async ({ sandbox }) => { + const text = '!@#$%^&*()_+' + + const cmd = await sandbox.commands.run(`echo "${text}"`) + + assert.equal(cmd.exitCode, 0) + assert.equal(cmd.stdout, `${text}\n`) +}) + +sandboxTest('run with multiline string', async ({ sandbox }) => { + const text = 'Hello,\nWorld!' + + const cmd = await sandbox.commands.run(`echo "${text}"`) + + assert.equal(cmd.exitCode, 0) + assert.equal(cmd.stdout, `${text}\n`) +}) + +sandboxTest('run with timeout', async ({ sandbox }) => { + const cmd = await sandbox.commands.run('echo "Hello, World!"', { + timeoutMs: 4000, + }) + + assert.equal(cmd.exitCode, 0) +}) + +sandboxTest('run with too short timeout', async ({ sandbox }) => { + await expect( + sandbox.commands.run('sleep 10', { timeoutMs: 1000 }) + ).rejects.toThrow() +}) + +sandboxTest('run with too short timeout iterating', async ({ sandbox }) => { + const handle = await sandbox.commands.run('sleep 10', { + timeoutMs: 2000, + background: true, + }) + + await expect(handle.wait()).rejects.toThrowError(TimeoutError) +}) diff --git a/packages/js-sdk/tests/sandbox/commands/sandboxKilledDuringRun.test.ts b/packages/js-sdk/tests/sandbox/commands/sandboxKilledDuringRun.test.ts new file mode 100644 index 0000000..511a7a3 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/commands/sandboxKilledDuringRun.test.ts @@ -0,0 +1,20 @@ +import { expect } from 'vitest' +import { TimeoutError } from '../../../src/index.js' + +import { sandboxTest, isDebug } from '../../setup.js' + +sandboxTest.skipIf(isDebug)( + 'killing the sandbox while a command is running throws an actionable error', + async ({ sandbox }) => { + const cmd = await sandbox.commands.run('sleep 60', { background: true }) + + await sandbox.kill() + + const err = await cmd.wait().catch((e) => e) + expect(err).toBeInstanceOf(TimeoutError) + // The health check confirms the sandbox is gone, so the error states it outright + expect(err.message).toContain( + 'sandbox was killed or reached its end of life' + ) + } +) diff --git a/packages/js-sdk/tests/sandbox/commands/sendStdin.test.ts b/packages/js-sdk/tests/sandbox/commands/sendStdin.test.ts new file mode 100644 index 0000000..da0831c --- /dev/null +++ b/packages/js-sdk/tests/sandbox/commands/sendStdin.test.ts @@ -0,0 +1,138 @@ +import { assert } from 'vitest' +import { sandboxTest } from '../../setup.js' + +sandboxTest('send stdin to process', async ({ sandbox }) => { + const text = 'Hello, World!' + const cmd = await sandbox.commands.run('cat', { + background: true, + stdin: true, + }) + + await sandbox.commands.sendStdin(cmd.pid, text) + + for (let i = 0; i < 5; i++) { + if (cmd.stdout === text) { + break + } + await new Promise((r) => setTimeout(r, 500)) + } + + await cmd.kill() + + assert.equal(cmd.stdout, text) +}) + +sandboxTest('send Uint8Array stdin to process', async ({ sandbox }) => { + const text = 'Hello, World!' + const cmd = await sandbox.commands.run('cat', { + background: true, + stdin: true, + }) + + await sandbox.commands.sendStdin(cmd.pid, new TextEncoder().encode(text)) + + for (let i = 0; i < 5; i++) { + if (cmd.stdout === text) { + break + } + await new Promise((r) => setTimeout(r, 500)) + } + + await cmd.kill() + + assert.equal(cmd.stdout, text) +}) + +sandboxTest('send stdin via command handle', async ({ sandbox }) => { + const text = 'Hello, World!' + const cmd = await sandbox.commands.run('cat', { + background: true, + stdin: true, + }) + + await cmd.sendStdin(text) + + for (let i = 0; i < 5; i++) { + if (cmd.stdout === text) { + break + } + await new Promise((r) => setTimeout(r, 500)) + } + + await cmd.kill() + + assert.equal(cmd.stdout, text) +}) + +sandboxTest('close stdin via command handle', async ({ sandbox }) => { + const text = 'Hello, World!' + const cmd = await sandbox.commands.run('cat', { + background: true, + stdin: true, + }) + + await cmd.sendStdin(text) + await cmd.closeStdin() + + // `cat` exits once stdin is closed (EOF). + const result = await cmd.wait() + + assert.equal(result.exitCode, 0) + assert.equal(result.stdout, text) +}) + +sandboxTest('send empty stdin to process', async ({ sandbox }) => { + const text = '' + const cmd = await sandbox.commands.run('cat', { + background: true, + stdin: true, + }) + + await sandbox.commands.sendStdin(cmd.pid, text) + + await cmd.kill() + + assert.equal(cmd.stdout, text) +}) + +sandboxTest('send special characters to stdin', async ({ sandbox }) => { + const text = '!@#$%^&*()_+' + const cmd = await sandbox.commands.run('cat', { + background: true, + stdin: true, + }) + + await sandbox.commands.sendStdin(cmd.pid, text) + + for (let i = 0; i < 5; i++) { + if (cmd.stdout === text) { + break + } + await new Promise((r) => setTimeout(r, 500)) + } + + await cmd.kill() + + assert.equal(cmd.stdout, text) +}) + +sandboxTest('send multiline string to stdin', async ({ sandbox }) => { + const text = 'Hello,\nWorld!' + const cmd = await sandbox.commands.run('cat', { + background: true, + stdin: true, + }) + + await sandbox.commands.sendStdin(cmd.pid, text) + + for (let i = 0; i < 5; i++) { + if (cmd.stdout === text) { + break + } + await new Promise((r) => setTimeout(r, 500)) + } + + await cmd.kill() + + assert.equal(cmd.stdout, text) +}) diff --git a/packages/js-sdk/tests/sandbox/configPropagation.test.ts b/packages/js-sdk/tests/sandbox/configPropagation.test.ts new file mode 100644 index 0000000..eef1314 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/configPropagation.test.ts @@ -0,0 +1,109 @@ +import { afterEach, assert, beforeEach, describe, test, vi } from 'vitest' + +import { Sandbox } from '../../src' +import { SandboxApi } from '../../src/sandbox/sandboxApi' +import { TEST_API_KEY } from '../setup' + +let originalSandboxUrl: string | undefined + +const baseConfig = { + apiKey: TEST_API_KEY, + domain: 'base.e2b.dev', + requestTimeoutMs: 1111, + debug: false, + apiHeaders: { 'X-Test': 'base' }, +} + +function createSandbox() { + return new Sandbox({ + sandboxId: 'sbx-test', + sandboxDomain: 'sandbox.e2b.dev', + envdVersion: '0.2.4', + envdAccessToken: 'tok', + trafficAccessToken: 'tok', + ...baseConfig, + }) +} + +describe('Sandbox API config propagation', () => { + beforeEach(() => { + originalSandboxUrl = process.env.E2B_SANDBOX_URL + }) + + afterEach(() => { + vi.restoreAllMocks() + if (originalSandboxUrl === undefined) { + delete process.env.E2B_SANDBOX_URL + } else { + process.env.E2B_SANDBOX_URL = originalSandboxUrl + } + }) + + test('passes connectionConfig to public API methods when called without overrides', async () => { + const pauseSpy = vi.spyOn(SandboxApi, 'pause').mockResolvedValue(true) + const sandbox = createSandbox() + + await sandbox.pause() + + const opts = pauseSpy.mock.calls[0][1] + assert.equal(opts?.apiKey, baseConfig.apiKey) + assert.equal(opts?.domain, baseConfig.domain) + assert.equal(opts?.requestTimeoutMs, baseConfig.requestTimeoutMs) + assert.equal(opts?.debug, baseConfig.debug) + assert.equal(opts?.headers?.['X-Test'], baseConfig.apiHeaders['X-Test']) + }) + + test('accepts apiHeaders in static Sandbox API options', () => { + const opts = { apiHeaders: baseConfig.apiHeaders } satisfies Parameters< + typeof Sandbox.kill + >[1] + + assert.equal(opts.apiHeaders['X-Test'], baseConfig.apiHeaders['X-Test']) + }) + + test('lets public method call overrides win over connectionConfig', async () => { + const pauseSpy = vi.spyOn(SandboxApi, 'pause').mockResolvedValue(true) + const sandbox = createSandbox() + + await sandbox.pause({ + domain: 'override.e2b.dev', + requestTimeoutMs: 9999, + }) + + const opts = pauseSpy.mock.calls[0][1] + assert.equal(opts?.apiKey, baseConfig.apiKey) + assert.equal(opts?.domain, 'override.e2b.dev') + assert.equal(opts?.requestTimeoutMs, 9999) + assert.equal(opts?.debug, baseConfig.debug) + }) + + test('updateNetwork forwards per-call signal', async () => { + const updateNetworkSpy = vi + .spyOn(SandboxApi, 'updateNetwork') + .mockResolvedValue() + const sandbox = createSandbox() + const controller = new AbortController() + + await sandbox.updateNetwork({}, { signal: controller.signal }) + + const opts = updateNetworkSpy.mock.calls[0][2] + assert.equal(opts?.signal, controller.signal) + }) + + test('downloadUrl keeps sandbox identity in production direct URLs', async () => { + delete process.env.E2B_SANDBOX_URL + + const sandbox = new Sandbox({ + sandboxId: 'sbx-test', + sandboxDomain: 'e2b.app', + envdVersion: '0.2.4', + domain: 'e2b.app', + debug: false, + }) + + assert.equal( + await sandbox.downloadUrl('/hello.txt'), + 'https://49983-sbx-test.e2b.app/files?username=user&path=%2Fhello.txt' + ) + }) +}) diff --git a/packages/js-sdk/tests/sandbox/connect.test.ts b/packages/js-sdk/tests/sandbox/connect.test.ts new file mode 100644 index 0000000..9b7ca5f --- /dev/null +++ b/packages/js-sdk/tests/sandbox/connect.test.ts @@ -0,0 +1,121 @@ +import { assert, test, expect, vi } from 'vitest' + +import { Sandbox } from '../../src' +import { isDebug, sandboxTest, template } from '../setup.js' + +test('connect in debug mode does not call the API', async () => { + const fetchSpy = vi.fn(() => { + throw new Error('unexpected request in debug mode') + }) + vi.stubGlobal('fetch', fetchSpy) + + try { + const sbx = await Sandbox.connect('debug-sandbox-id', { + debug: true, + apiKey: 'test-api-key', + }) + assert.equal(sbx.sandboxId, 'debug-sandbox-id') + + const sameSbx = await sbx.connect() + assert.strictEqual(sameSbx, sbx) + + expect(fetchSpy).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } +}) + +test.skipIf(isDebug)('connect', async () => { + const sbx = await Sandbox.create(template, { timeoutMs: 10_000 }) + + try { + const isRunning = await sbx.isRunning() + assert.isTrue(isRunning) + + const sbxConnection = await Sandbox.connect(sbx.sandboxId) + const isRunning2 = await sbxConnection.isRunning() + assert.isTrue(isRunning2) + } finally { + if (!isDebug) { + await sbx.kill() + } + } +}) + +sandboxTest.skipIf(isDebug)( + 'connect resumes paused sandbox', + async ({ sandbox }) => { + await sandbox.pause() + assert.isFalse(await sandbox.isRunning()) + + const resumed = await Sandbox.connect(sandbox.sandboxId) + assert.isTrue(await resumed.isRunning()) + } +) + +sandboxTest.skipIf(isDebug)( + 'connect to non-running sandbox', + async ({ sandbox }) => { + const isRunning = await sandbox.isRunning() + assert.isTrue(isRunning) + await sandbox.kill() + + const connectPromise = Sandbox.connect(sandbox.sandboxId) + await expect(connectPromise).rejects.toThrowError( + expect.objectContaining({ + name: 'SandboxNotFoundError', + }) + ) + } +) + +test.skipIf(isDebug)( + 'connect does not shorten timeout on running sandbox', + async () => { + // Create sandbox with a 300 second timeout + const sbx = await Sandbox.create(template, { timeoutMs: 300_000 }) + + try { + const isRunning = await sbx.isRunning() + assert.isTrue(isRunning) + + // Get initial info to check endAt + const infoBefore = await Sandbox.getInfo(sbx.sandboxId) + + // Connect with a shorter timeout (10 seconds) + await Sandbox.connect(sbx.sandboxId, { timeoutMs: 10_000 }) + + // Get info after connection + const infoAfter = await sbx.getInfo() + + // The endAt time should not have been shortened. It should be the same + assert.equal( + infoAfter.endAt.getTime(), + infoBefore.endAt.getTime(), + `Timeout was shortened: before=${infoBefore.endAt.toISOString()}, after=${infoAfter.endAt.toISOString()}` + ) + } finally { + await sbx.kill() + } + } +) + +sandboxTest.skipIf(isDebug)( + 'connect extends timeout on running sandbox', + async ({ sandbox }) => { + // Get initial info to check endAt + const infoBefore = await sandbox.getInfo() + + // Connect with a longer timeout + await sandbox.connect({ timeoutMs: 600_000 }) + + // Get info after connection + const infoAfter = await sandbox.getInfo() + + // The endAt time should have been extended + assert.isTrue( + infoAfter.endAt.getTime() > infoBefore.endAt.getTime(), + `Timeout was not extended: before=${infoBefore.endAt.toISOString()}, after=${infoAfter.endAt.toISOString()}` + ) + } +) diff --git a/packages/js-sdk/tests/sandbox/create.test.ts b/packages/js-sdk/tests/sandbox/create.test.ts new file mode 100644 index 0000000..69251c8 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/create.test.ts @@ -0,0 +1,34 @@ +import { assert, test } from 'vitest' + +import { Sandbox } from '../../src' +import { template, isDebug } from '../setup.js' + +test.skipIf(isDebug)('create', async () => { + const sbx = await Sandbox.create(template, { timeoutMs: 5_000 }) + try { + const isRunning = await sbx.isRunning() + // @ts-ignore It's only for testing + assert.isDefined(sbx.envdApi.version) + assert.isTrue(isRunning) + } finally { + await sbx.kill() + } +}) + +test.skipIf(isDebug)('metadata', async () => { + const metadata = { + 'test-key': 'test-value', + } + + const sbx = await Sandbox.create(template, { timeoutMs: 5_000, metadata }) + + try { + const paginator = Sandbox.list() + const sbxs = await paginator.nextItems() + const sbxInfo = sbxs.find((s) => s.sandboxId === sbx.sandboxId) + + assert.deepEqual(sbxInfo?.metadata, metadata) + } finally { + await sbx.kill() + } +}) diff --git a/packages/js-sdk/tests/sandbox/files/contentEncoding.test.ts b/packages/js-sdk/tests/sandbox/files/contentEncoding.test.ts new file mode 100644 index 0000000..2135cdc --- /dev/null +++ b/packages/js-sdk/tests/sandbox/files/contentEncoding.test.ts @@ -0,0 +1,94 @@ +import { assert } from 'vitest' + +import { WriteEntry } from '../../../src/sandbox/filesystem' +import { isDebug, sandboxTest } from '../../setup.js' + +sandboxTest( + 'write and read file with gzip content encoding', + async ({ sandbox }) => { + const filename = 'test_gzip_write.txt' + const content = 'This is a test file with gzip encoding.' + + const info = await sandbox.files.write(filename, content, { + gzip: true, + }) + assert.equal(info.name, filename) + assert.equal(info.type, 'file') + assert.equal(info.path, `/home/user/${filename}`) + + const readContent = await sandbox.files.read(filename, { + gzip: true, + }) + assert.equal(readContent, content) + + if (isDebug) { + await sandbox.files.remove(filename) + } + } +) + +sandboxTest( + 'write with gzip and read without encoding', + async ({ sandbox }) => { + const filename = 'test_gzip_write_plain_read.txt' + const content = 'Written with gzip, read without.' + + await sandbox.files.write(filename, content, { + gzip: true, + }) + + const readContent = await sandbox.files.read(filename) + assert.equal(readContent, content) + + if (isDebug) { + await sandbox.files.remove(filename) + } + } +) + +sandboxTest('writeFiles with gzip content encoding', async ({ sandbox }) => { + const files: WriteEntry[] = [ + { path: 'gzip_multi_1.txt', data: 'File 1 content' }, + { path: 'gzip_multi_2.txt', data: 'File 2 content' }, + { path: 'gzip_multi_3.txt', data: 'File 3 content' }, + ] + + const infos = await sandbox.files.writeFiles(files, { + gzip: true, + }) + + assert.equal(infos.length, files.length) + + for (let i = 0; i < files.length; i++) { + const readContent = await sandbox.files.read(files[i].path) + assert.equal(readContent, files[i].data) + } + + if (isDebug) { + for (const file of files) { + await sandbox.files.remove(file.path) + } + } +}) + +sandboxTest( + 'read file as bytes with gzip content encoding', + async ({ sandbox }) => { + const filename = 'test_gzip_bytes.txt' + const content = 'Binary content with gzip.' + + await sandbox.files.write(filename, content) + + const readBytes = await sandbox.files.read(filename, { + format: 'bytes', + gzip: true, + }) + assert.instanceOf(readBytes, Uint8Array) + const decoded = new TextDecoder().decode(readBytes) + assert.equal(decoded, content) + + if (isDebug) { + await sandbox.files.remove(filename) + } + } +) diff --git a/packages/js-sdk/tests/sandbox/files/exists.test.ts b/packages/js-sdk/tests/sandbox/files/exists.test.ts new file mode 100644 index 0000000..4ebc0ce --- /dev/null +++ b/packages/js-sdk/tests/sandbox/files/exists.test.ts @@ -0,0 +1,18 @@ +import { assert } from 'vitest' + +import { sandboxTest } from '../../setup.js' + +sandboxTest('file exists', async ({ sandbox }) => { + const filename = 'test_exists.txt' + + await sandbox.files.write(filename, 'test') + const exists = await sandbox.files.exists(filename) + assert.isTrue(exists) +}) + +sandboxTest('file does not exist', async ({ sandbox }) => { + const filename = 'test_does_not_exist.txt' + + const exists = await sandbox.files.exists(filename) + assert.isFalse(exists) +}) diff --git a/packages/js-sdk/tests/sandbox/files/info.test.ts b/packages/js-sdk/tests/sandbox/files/info.test.ts new file mode 100644 index 0000000..7e5bd46 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/files/info.test.ts @@ -0,0 +1,72 @@ +import { assert } from 'vitest' +import { expect } from 'vitest' +import { FileNotFoundError } from '../../../src/errors.js' + +import { sandboxTest } from '../../setup.js' + +sandboxTest('get info of a file', async ({ sandbox }) => { + const filename = 'test_file.txt' + + await sandbox.files.write(filename, 'test') + const info = await sandbox.files.getInfo(filename) + const { stdout: currentPath } = await sandbox.commands.run('pwd') + + assert.equal(info.name, filename) + assert.equal(info.type, 'file') + assert.equal(info.path, currentPath.trim() + '/' + filename) + assert.equal(info.size, 4) + assert.equal(info.mode, 0o644) + assert.equal(info.permissions, '-rw-r--r--') + assert.equal(info.owner, 'user') + assert.equal(info.group, 'user') + assert.property(info, 'modifiedTime') +}) + +sandboxTest('get info of a file that does not exist', async ({ sandbox }) => { + const filename = 'test_does_not_exist.txt' + await expect(sandbox.files.getInfo(filename)).rejects.toThrow( + FileNotFoundError + ) +}) + +sandboxTest('get info of a directory', async ({ sandbox }) => { + const dirname = 'test_dir' + + await sandbox.files.makeDir(dirname) + const info = await sandbox.files.getInfo(dirname) + const { stdout: currentPath } = await sandbox.commands.run('pwd') + + assert.equal(info.name, dirname) + assert.equal(info.type, 'dir') + assert.equal(info.path, currentPath.trim() + '/' + dirname) + assert.isAbove(info.size, 0) + assert.equal(info.mode, 0o755) + assert.equal(info.permissions, 'drwxr-xr-x') + assert.equal(info.owner, 'user') + assert.equal(info.group, 'user') + assert.property(info, 'modifiedTime') +}) + +sandboxTest( + 'get info of a directory that does not exist', + async ({ sandbox }) => { + const dirname = 'test_does_not_exist_dir' + + await expect(sandbox.files.getInfo(dirname)).rejects.toThrow( + FileNotFoundError + ) + } +) + +sandboxTest('get info of a symlink', async ({ sandbox }) => { + const filename = 'test_file.txt' + + await sandbox.files.write(filename, 'test') + const symlinkName = 'test_symlink.txt' + await sandbox.commands.run(`ln -s ${filename} ${symlinkName}`) + + const info = await sandbox.files.getInfo(symlinkName) + const { stdout: currentPath } = await sandbox.commands.run('pwd') + assert.equal(info.name, symlinkName) + assert.equal(info.symlinkTarget, currentPath.trim() + '/' + filename) +}) diff --git a/packages/js-sdk/tests/sandbox/files/list.test.ts b/packages/js-sdk/tests/sandbox/files/list.test.ts new file mode 100644 index 0000000..fbadab6 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/files/list.test.ts @@ -0,0 +1,206 @@ +import { assert } from 'vitest' + +import { sandboxTest } from '../../setup.js' + +const parentDirName = 'test_directory' + +sandboxTest('list directory', async ({ sandbox }) => { + const homeDirName = '/home/user' + await sandbox.files.makeDir(parentDirName) + await sandbox.files.makeDir(`${parentDirName}/subdir1`) + await sandbox.files.makeDir(`${parentDirName}/subdir2`) + await sandbox.files.makeDir(`${parentDirName}/subdir1/subdir1_1`) + await sandbox.files.makeDir(`${parentDirName}/subdir1/subdir1_2`) + await sandbox.files.makeDir(`${parentDirName}/subdir2/subdir2_1`) + await sandbox.files.makeDir(`${parentDirName}/subdir2/subdir2_2`) + await sandbox.files.write(`${parentDirName}/file1.txt`, 'Hello, world!') + + const testCases = [ + { + test_name: 'default depth (1)', + depth: undefined, + expectedLen: 3, + expectedFileNames: ['file1.txt', 'subdir1', 'subdir2'], + expectedFileTypes: ['file', 'dir', 'dir'], + expectedFilePaths: [ + `${homeDirName}/${parentDirName}/file1.txt`, + `${homeDirName}/${parentDirName}/subdir1`, + `${homeDirName}/${parentDirName}/subdir2`, + ], + }, + { + test_name: 'explicit depth 1', + depth: 1, + expectedLen: 3, + expectedFileNames: ['file1.txt', 'subdir1', 'subdir2'], + expectedFileTypes: ['file', 'dir', 'dir'], + expectedFilePaths: [ + `${homeDirName}/${parentDirName}/file1.txt`, + `${homeDirName}/${parentDirName}/subdir1`, + `${homeDirName}/${parentDirName}/subdir2`, + ], + }, + { + test_name: 'explicit depth 2', + depth: 2, + expectedLen: 7, + expectedFileTypes: ['file', 'dir', 'dir', 'dir', 'dir', 'dir', 'dir'], + expectedFileNames: [ + 'file1.txt', + 'subdir1', + 'subdir1_1', + 'subdir1_2', + 'subdir2', + 'subdir2_1', + 'subdir2_2', + ], + expectedFilePaths: [ + `${homeDirName}/${parentDirName}/file1.txt`, + `${homeDirName}/${parentDirName}/subdir1`, + `${homeDirName}/${parentDirName}/subdir1/subdir1_1`, + `${homeDirName}/${parentDirName}/subdir1/subdir1_2`, + `${homeDirName}/${parentDirName}/subdir2`, + `${homeDirName}/${parentDirName}/subdir2/subdir2_1`, + `${homeDirName}/${parentDirName}/subdir2/subdir2_2`, + ], + }, + { + test_name: 'explicit depth 3 (should be the same as depth 2)', + depth: 3, + expectedLen: 7, + expectedFileTypes: ['file', 'dir', 'dir', 'dir', 'dir', 'dir', 'dir'], + expectedFileNames: [ + 'file1.txt', + 'subdir1', + 'subdir1_1', + 'subdir1_2', + 'subdir2', + 'subdir2_1', + 'subdir2_2', + ], + expectedFilePaths: [ + `${homeDirName}/${parentDirName}/file1.txt`, + `${homeDirName}/${parentDirName}/subdir1`, + `${homeDirName}/${parentDirName}/subdir1/subdir1_1`, + `${homeDirName}/${parentDirName}/subdir1/subdir1_2`, + `${homeDirName}/${parentDirName}/subdir2`, + `${homeDirName}/${parentDirName}/subdir2/subdir2_1`, + `${homeDirName}/${parentDirName}/subdir2/subdir2_2`, + ], + }, + ] + + for (const testCase of testCases) { + const files = await sandbox.files.list( + parentDirName, + testCase.depth !== undefined ? { depth: testCase.depth } : undefined + ) + assert.equal(files.length, testCase.expectedLen) + + for (let i = 0; i < testCase.expectedFilePaths.length; i++) { + assert.equal(files[i].type, testCase.expectedFileTypes[i]) + assert.equal(files[i].name, testCase.expectedFileNames[i]) + assert.equal(files[i].path, testCase.expectedFilePaths[i]) + } + } +}) + +sandboxTest('list directory with invalid depth', async ({ sandbox }) => { + await sandbox.files.makeDir(parentDirName) + + try { + await sandbox.files.list(parentDirName, { depth: -1 }) + assert.fail('Expected error but none was thrown') + } catch (err) { + const expectedErrorMessage = 'depth should be at least one' + assert.ok( + err.message.includes(expectedErrorMessage), + `expected error message to include "${expectedErrorMessage}"` + ) + } +}) + +sandboxTest('file entry details', async ({ sandbox }) => { + const testDir = 'test-file-entry' + const filePath = `${testDir}/test.txt` + const content = 'Hello, World!' + + await sandbox.files.makeDir(testDir) + await sandbox.files.write(filePath, content) + + const files = await sandbox.files.list(testDir, { depth: 1 }) + assert.equal(files.length, 1) + + const fileEntry = files[0] + assert.equal(fileEntry.name, 'test.txt') + assert.equal(fileEntry.path, `/home/user/${filePath}`) + assert.equal(fileEntry.type, 'file') + assert.equal(fileEntry.mode, 0o644) + assert.equal(fileEntry.permissions, '-rw-r--r--') + assert.equal(fileEntry.owner, 'user') + assert.equal(fileEntry.group, 'user') + assert.equal(fileEntry.size, content.length) + assert.ok(fileEntry.modifiedTime) + assert.isUndefined(fileEntry.symlinkTarget) +}) + +sandboxTest('directory entry details', async ({ sandbox }) => { + const testDir = 'test-entry-info' + const subDir = `${testDir}/subdir` + + await sandbox.files.makeDir(testDir) + await sandbox.files.makeDir(subDir) + + const files = await sandbox.files.list(testDir, { depth: 1 }) + assert.equal(files.length, 1) + + const dirEntry = files[0] + assert.equal(dirEntry.name, 'subdir') + assert.equal(dirEntry.path, `/home/user/${subDir}`) + assert.equal(dirEntry.type, 'dir') + assert.equal(dirEntry.mode, 0o755) + assert.equal(dirEntry.permissions, 'drwxr-xr-x') + assert.equal(dirEntry.owner, 'user') + assert.equal(dirEntry.group, 'user') + assert.ok(dirEntry.modifiedTime) +}) + +sandboxTest('mixed entries (files and directories)', async ({ sandbox }) => { + const testDir = 'test-mixed-entries' + const subDir = `${testDir}/subdir` + const filePath = `${testDir}/test.txt` + const content = 'Hello, World!' + + await sandbox.files.makeDir(testDir) + await sandbox.files.makeDir(subDir) + await sandbox.files.write(filePath, content) + + const files = await sandbox.files.list(testDir, { depth: 1 }) + assert.equal(files.length, 2) + + // Create a map of entries by name for easier verification + const entries = new Map(files.map((entry) => [entry.name, entry])) + + // Verify directory entry + const dirEntry = entries.get('subdir') + assert.ok(dirEntry) + assert.equal(dirEntry!.path, `/home/user/${subDir}`) + assert.equal(dirEntry!.type, 'dir') + assert.equal(dirEntry!.mode, 0o755) + assert.equal(dirEntry!.permissions, 'drwxr-xr-x') + assert.equal(dirEntry!.owner, 'user') + assert.equal(dirEntry!.group, 'user') + assert.ok(dirEntry!.modifiedTime) + + // Verify file entry + const fileEntry = entries.get('test.txt') + assert.ok(fileEntry) + assert.equal(fileEntry!.path, `/home/user/${filePath}`) + assert.equal(fileEntry!.type, 'file') + assert.equal(fileEntry!.mode, 0o644) + assert.equal(fileEntry!.permissions, '-rw-r--r--') + assert.equal(fileEntry!.owner, 'user') + assert.equal(fileEntry!.group, 'user') + assert.equal(fileEntry!.size, content.length) + assert.ok(fileEntry!.modifiedTime) +}) diff --git a/packages/js-sdk/tests/sandbox/files/makeDir.test.ts b/packages/js-sdk/tests/sandbox/files/makeDir.test.ts new file mode 100644 index 0000000..87759bc --- /dev/null +++ b/packages/js-sdk/tests/sandbox/files/makeDir.test.ts @@ -0,0 +1,30 @@ +import { assert } from 'vitest' + +import { sandboxTest } from '../../setup.js' + +sandboxTest('make directory', async ({ sandbox }) => { + const dirName = 'test_directory1' + + await sandbox.files.makeDir(dirName) + const exists = await sandbox.files.exists(dirName) + assert.isTrue(exists) +}) + +sandboxTest('make existing directory', async ({ sandbox }) => { + const dirName = 'test_directory2' + + await sandbox.files.makeDir(dirName) + const exists = await sandbox.files.exists(dirName) + assert.isTrue(exists) + + const exists2 = await sandbox.files.makeDir(dirName) + assert.isFalse(exists2) +}) + +sandboxTest('make nested directory', async ({ sandbox }) => { + const nestedDirName = 'test_directory3/nested_directory' + + await sandbox.files.makeDir(nestedDirName) + const exists = await sandbox.files.exists(nestedDirName) + assert.isTrue(exists) +}) diff --git a/packages/js-sdk/tests/sandbox/files/metadata.test.ts b/packages/js-sdk/tests/sandbox/files/metadata.test.ts new file mode 100644 index 0000000..025f7cd --- /dev/null +++ b/packages/js-sdk/tests/sandbox/files/metadata.test.ts @@ -0,0 +1,194 @@ +import { assert, expect } from 'vitest' + +import { WriteEntry } from '../../../src/sandbox/filesystem' +import { InvalidArgumentError } from '../../../src/errors.js' +import { isDebug, sandboxTest } from '../../setup.js' + +sandboxTest('write file with metadata', async ({ sandbox }) => { + const filename = 'test_metadata.txt' + const content = 'This is a test file with metadata.' + const metadata = { author: 'mish', purpose: 'upload' } + + const info = await sandbox.files.write(filename, content, { metadata }) + assert.isFalse(Array.isArray(info)) + assert.deepEqual(info.metadata, metadata) + + // Metadata is persisted and surfaced on subsequent reads. + const stat = await sandbox.files.getInfo(filename) + assert.deepEqual(stat.metadata, metadata) + + if (isDebug) { + await sandbox.files.remove(filename) + } +}) + +sandboxTest( + 'write file with metadata using octet-stream', + async ({ sandbox }) => { + const filename = 'test_metadata_octet.txt' + const content = 'This is a test file with metadata.' + const metadata = { author: 'mish', purpose: 'upload' } + + const info = await sandbox.files.write(filename, content, { + metadata, + useOctetStream: true, + }) + assert.deepEqual(info.metadata, metadata) + + const stat = await sandbox.files.getInfo(filename) + assert.deepEqual(stat.metadata, metadata) + + if (isDebug) { + await sandbox.files.remove(filename) + } + } +) + +sandboxTest('write file without metadata', async ({ sandbox }) => { + const filename = 'test_no_metadata.txt' + + const info = await sandbox.files.write(filename, 'no metadata here') + assert.isUndefined(info.metadata) + + const stat = await sandbox.files.getInfo(filename) + assert.isUndefined(stat.metadata) + + if (isDebug) { + await sandbox.files.remove(filename) + } +}) + +sandboxTest( + 'writeFiles applies metadata to every file', + async ({ sandbox }) => { + // The same metadata is applied to every file in the upload. + const metadata = { source: 'test-suite' } + const files: WriteEntry[] = [ + { path: 'metadata_multi_1.txt', data: 'File 1' }, + { path: 'metadata_multi_2.txt', data: 'File 2' }, + ] + + const infos = await sandbox.files.writeFiles(files, { metadata }) + assert.equal(infos.length, files.length) + + for (const info of infos) { + assert.deepEqual(info.metadata, metadata) + + const stat = await sandbox.files.getInfo(info.path) + assert.deepEqual(stat.metadata, metadata) + } + + if (isDebug) { + for (const file of files) { + await sandbox.files.remove(file.path) + } + } + } +) + +sandboxTest('metadata is surfaced when listing', async ({ sandbox }) => { + const dirname = 'metadata_list_dir' + const filename = 'listed.txt' + const metadata = { tag: 'listed' } + + await sandbox.files.makeDir(dirname) + await sandbox.files.write(`${dirname}/${filename}`, 'content', { metadata }) + + const entries = await sandbox.files.list(dirname) + const entry = entries.find((e) => e.name === filename) + assert.isDefined(entry) + assert.deepEqual(entry?.metadata, metadata) + + if (isDebug) { + await sandbox.files.remove(dirname) + } +}) + +sandboxTest('metadata is surfaced after rename', async ({ sandbox }) => { + const oldPath = 'metadata_rename_old.txt' + const newPath = 'metadata_rename_new.txt' + const metadata = { stage: 'renamed' } + + await sandbox.files.write(oldPath, 'content', { metadata }) + const info = await sandbox.files.rename(oldPath, newPath) + assert.deepEqual(info.metadata, metadata) + + if (isDebug) { + await sandbox.files.remove(newPath) + } +}) + +sandboxTest('overwriting a file clears stale metadata', async ({ sandbox }) => { + const filename = 'metadata_overwrite.txt' + + await sandbox.files.write(filename, 'first', { + metadata: { author: 'mish' }, + }) + + // Overwriting without metadata removes the previously stored metadata. + const info = await sandbox.files.write(filename, 'second') + assert.isUndefined(info.metadata) + + const stat = await sandbox.files.getInfo(filename) + assert.isUndefined(stat.metadata) + + if (isDebug) { + await sandbox.files.remove(filename) + } +}) + +sandboxTest( + 'metadata set via xattrs is surfaced in getInfo', + async ({ sandbox }) => { + const filename = 'metadata_xattr.txt' + await sandbox.files.write(filename, 'content') + + const { stdout: filePath } = await sandbox.commands.run( + `realpath ${filename}` + ) + + // Set an xattr directly in the `user.e2b.` namespace (out-of-band, not via + // the SDK upload); it should surface as metadata (with the namespace prefix + // stripped) when reading the file info. + await sandbox.commands.run( + `python3 -c "import os; os.setxattr('${filePath.trim()}', 'user.e2b.author', b'mish')"` + ) + + const info = await sandbox.files.getInfo(filename) + assert.deepEqual(info.metadata, { author: 'mish' }) + + if (isDebug) { + await sandbox.files.remove(filename) + } + } +) + +sandboxTest('rejects invalid metadata', async ({ sandbox }) => { + const filename = 'invalid_metadata.txt' + + // Key with a space is not a valid HTTP header token. + await expect( + sandbox.files.write(filename, 'x', { metadata: { 'bad key': 'value' } }) + ).rejects.toThrow(InvalidArgumentError) + + // Empty key. + await expect( + sandbox.files.write(filename, 'x', { metadata: { '': 'value' } }) + ).rejects.toThrow(InvalidArgumentError) + + // Value with a non-printable / non-ASCII character. + await expect( + sandbox.files.write(filename, 'x', { metadata: { good: 'bad\nvalue' } }) + ).rejects.toThrow(InvalidArgumentError) + + // Trailing newline in value or key. + await expect( + sandbox.files.write(filename, 'x', { metadata: { good: 'value\n' } }) + ).rejects.toThrow(InvalidArgumentError) + await expect( + sandbox.files.write(filename, 'x', { metadata: { 'key\n': 'value' } }) + ).rejects.toThrow(InvalidArgumentError) + + // The file must not have been created by a rejected write. + assert.isFalse(await sandbox.files.exists(filename)) +}) diff --git a/packages/js-sdk/tests/sandbox/files/read.test.ts b/packages/js-sdk/tests/sandbox/files/read.test.ts new file mode 100644 index 0000000..85b8f45 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/files/read.test.ts @@ -0,0 +1,126 @@ +import { expect, assert } from 'vitest' + +import { FileNotFoundError, NotFoundError } from '../../../src' +import { sandboxTest } from '../../setup.js' + +sandboxTest('read file', async ({ sandbox }) => { + const filename = 'test_read.txt' + const content = 'Hello, world!' + + await sandbox.files.write(filename, content) + const readContent = await sandbox.files.read(filename) + assert.equal(readContent, content) +}) + +sandboxTest('read non-existing file', async ({ sandbox }) => { + const filename = 'non_existing_file.txt' + + await expect(sandbox.files.read(filename)).rejects.toThrowError( + FileNotFoundError + ) +}) + +sandboxTest( + 'read non-existing file catches with deprecated NotFoundError', + async ({ sandbox }) => { + const filename = 'non_existing_file.txt' + + await expect(sandbox.files.read(filename)).rejects.toThrowError( + NotFoundError + ) + } +) + +sandboxTest('read file as stream', async ({ sandbox }) => { + const filename = 'test_read_stream.txt' + const content = 'Streamed read content. '.repeat(10_000) + + await sandbox.files.write(filename, content) + const stream = await sandbox.files.read(filename, { format: 'stream' }) + + const chunks: Uint8Array[] = [] + for await (const chunk of stream as unknown as AsyncIterable) { + chunks.push(chunk) + } + const readContent = Buffer.concat(chunks).toString('utf-8') + assert.equal(readContent, content) +}) + +sandboxTest('read non-existing file as stream', async ({ sandbox }) => { + const filename = 'non_existing_file.txt' + + await expect( + sandbox.files.read(filename, { format: 'stream' }) + ).rejects.toThrowError(FileNotFoundError) +}) + +sandboxTest('read empty file in all formats', async ({ sandbox }) => { + const filename = 'empty-file-formats.txt' + await sandbox.commands.run(`touch ${filename}`) + + const text = await sandbox.files.read(filename, { format: 'text' }) + expect(text).toBe('') + + const bytes = await sandbox.files.read(filename, { format: 'bytes' }) + expect(bytes).toBeInstanceOf(Uint8Array) + expect(bytes.length).toBe(0) + + const blob = await sandbox.files.read(filename, { format: 'blob' }) + expect(blob).toBeInstanceOf(Blob) + expect(blob.size).toBe(0) + + const stream = await sandbox.files.read(filename, { format: 'stream' }) + expect(stream).toBeInstanceOf(ReadableStream) + const chunks: Uint8Array[] = [] + for await (const chunk of stream as unknown as AsyncIterable) { + chunks.push(chunk) + } + expect(Buffer.concat(chunks).length).toBe(0) +}) + +sandboxTest('read file as stream', async ({ sandbox }) => { + const filename = 'test_read_stream.txt' + const content = 'Streamed read content. '.repeat(10_000) + + await sandbox.files.write(filename, content) + const stream = await sandbox.files.read(filename, { format: 'stream' }) + + const chunks: Uint8Array[] = [] + for await (const chunk of stream as unknown as AsyncIterable) { + chunks.push(chunk) + } + const readContent = Buffer.concat(chunks).toString('utf-8') + assert.equal(readContent, content) +}) + +sandboxTest('read non-existing file as stream', async ({ sandbox }) => { + const filename = 'non_existing_file.txt' + + await expect( + sandbox.files.read(filename, { format: 'stream' }) + ).rejects.toThrowError(FileNotFoundError) +}) + +sandboxTest('read empty file in all formats', async ({ sandbox }) => { + const filename = 'empty-file-formats.txt' + await sandbox.commands.run(`touch ${filename}`) + + const text = await sandbox.files.read(filename, { format: 'text' }) + expect(text).toBe('') + + const bytes = await sandbox.files.read(filename, { format: 'bytes' }) + expect(bytes).toBeInstanceOf(Uint8Array) + expect(bytes.length).toBe(0) + + const blob = await sandbox.files.read(filename, { format: 'blob' }) + expect(blob).toBeInstanceOf(Blob) + expect(blob.size).toBe(0) + + const stream = await sandbox.files.read(filename, { format: 'stream' }) + expect(stream).toBeInstanceOf(ReadableStream) + const chunks: Uint8Array[] = [] + for await (const chunk of stream as unknown as AsyncIterable) { + chunks.push(chunk) + } + expect(Buffer.concat(chunks).length).toBe(0) +}) diff --git a/packages/js-sdk/tests/sandbox/files/remove.test.ts b/packages/js-sdk/tests/sandbox/files/remove.test.ts new file mode 100644 index 0000000..11026d3 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/files/remove.test.ts @@ -0,0 +1,20 @@ +import { assert } from 'vitest' + +import { sandboxTest } from '../../setup.js' + +sandboxTest('remove file', async ({ sandbox }) => { + const filename = 'test_remove.txt' + const content = 'This file will be removed.' + + await sandbox.files.write(filename, content) + await sandbox.files.remove(filename) + + const exists = await sandbox.files.exists(filename) + assert.isFalse(exists) +}) + +sandboxTest('remove non-existing file', async ({ sandbox }) => { + const filename = 'non_existing_file.txt' + + await sandbox.files.remove(filename) +}) diff --git a/packages/js-sdk/tests/sandbox/files/rename.test.ts b/packages/js-sdk/tests/sandbox/files/rename.test.ts new file mode 100644 index 0000000..d85e9db --- /dev/null +++ b/packages/js-sdk/tests/sandbox/files/rename.test.ts @@ -0,0 +1,32 @@ +import { assert, expect } from 'vitest' + +import { FileNotFoundError } from '../../../src' +import { sandboxTest } from '../../setup.js' + +sandboxTest('rename file', async ({ sandbox }) => { + const oldFilename = 'test_rename_old.txt' + const newFilename = 'test_rename_new.txt' + const content = 'This file will be renamed.' + + await sandbox.files.write(oldFilename, content) + const info = await sandbox.files.rename(oldFilename, newFilename) + assert.equal(info.name, newFilename) + assert.equal(info.type, 'file') + assert.equal(info.path, `/home/user/${newFilename}`) + + const existsOld = await sandbox.files.exists(oldFilename) + const existsNew = await sandbox.files.exists(newFilename) + assert.isFalse(existsOld) + assert.isTrue(existsNew) + const readContent = await sandbox.files.read(newFilename) + assert.equal(readContent, content) +}) + +sandboxTest('rename non-existing file', async ({ sandbox }) => { + const oldFilename = 'non_existing_file.txt' + const newFilename = 'new_non_existing_file.txt' + + await expect( + sandbox.files.rename(oldFilename, newFilename) + ).rejects.toThrowError(FileNotFoundError) +}) diff --git a/packages/js-sdk/tests/sandbox/files/signing.test.ts b/packages/js-sdk/tests/sandbox/files/signing.test.ts new file mode 100644 index 0000000..fb927a8 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/files/signing.test.ts @@ -0,0 +1,152 @@ +import { assert, describe } from 'vitest' + +import { sandboxTest, isDebug } from '../../setup' + +describe('file signing', () => { + sandboxTest.scoped({ + sandboxOpts: { + secure: true, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'test access file with expired signing', + async ({ sandbox }) => { + await sandbox.files.write('hello.txt', 'hello world') + + const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', { + useSignatureExpiration: -10_000, + }) + + const res = await fetch(fileUrlWithSigning) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 401) + assert.deepEqual(JSON.parse(resBody), { + code: 401, + message: 'signature is already expired', + }) + } + ) + + sandboxTest.skipIf(isDebug)( + 'test access file with valid signing', + async ({ sandbox }) => { + await sandbox.files.write('hello.txt', 'hello world') + + const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', { + useSignatureExpiration: 10_000, + }) + + const res = await fetch(fileUrlWithSigning) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 200) + assert.equal(resBody, 'hello world') + } + ) + + sandboxTest.skipIf(isDebug)( + 'test access file with valid signing as root', + async ({ sandbox }) => { + await sandbox.files.write('hello.txt', 'hello world', { user: 'root' }) + + const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', { + user: 'root', + useSignatureExpiration: 10_000, + }) + + const res = await fetch(fileUrlWithSigning) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 200) + assert.equal(resBody, 'hello world') + } + ) + + sandboxTest.skipIf(isDebug)( + 'test upload file with valid signing', + async ({ sandbox }) => { + const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', { + useSignatureExpiration: 10_000, + }) + + const form = new FormData() + form.append('file', 'file content') + + const res = await fetch(fileUrlWithSigning, { + method: 'POST', + body: form, + }) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 200) + assert.deepEqual(JSON.parse(resBody), [ + { name: 'hello.txt', path: '/home/user/hello.txt', type: 'file' }, + ]) + } + ) + + sandboxTest.skipIf(isDebug)( + 'test upload file with valid signing as root user', + async ({ sandbox }) => { + const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', { + user: 'root', + useSignatureExpiration: 10_000, + }) + + const form = new FormData() + form.append('file', 'file content') + + const res = await fetch(fileUrlWithSigning, { + method: 'POST', + body: form, + }) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 200) + assert.deepEqual(JSON.parse(resBody), [ + { name: 'hello.txt', path: '/root/hello.txt', type: 'file' }, + ]) + } + ) + + sandboxTest.skipIf(isDebug)( + 'test upload file with invalid signing', + async ({ sandbox }) => { + const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', { + useSignatureExpiration: -100_000, + }) + + const form = new FormData() + form.append('file', 'file content') + + const res = await fetch(fileUrlWithSigning, { + method: 'POST', + body: form, + }) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 401) + assert.deepEqual(JSON.parse(resBody), { + code: 401, + message: 'signature is already expired', + }) + } + ) + + sandboxTest.skipIf(isDebug)( + 'test command run with secured sbx', + async ({ sandbox }) => { + const response = await sandbox.commands.run('echo Hello World!') + + assert.equal(response.stdout, 'Hello World!\n') + } + ) +}) diff --git a/packages/js-sdk/tests/sandbox/files/watch.test.ts b/packages/js-sdk/tests/sandbox/files/watch.test.ts new file mode 100644 index 0000000..850d4a4 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/files/watch.test.ts @@ -0,0 +1,236 @@ +import { expect, onTestFinished } from 'vitest' + +import { isDebug, sandboxTest } from '../../setup.js' +import { + FileNotFoundError, + FilesystemEvent, + FilesystemEventType, + FileType, + SandboxError, +} from '../../../src' + +sandboxTest('watch directory changes', async ({ sandbox }) => { + const dirname = 'test_watch_dir' + const filename = 'test_watch.txt' + const content = 'This file will be watched.' + const newContent = 'This file has been modified.' + + await sandbox.files.makeDir(dirname) + await sandbox.files.write(`${dirname}/${filename}`, content) + + let trigger: () => void + + const eventPromise = new Promise((resolve) => { + trigger = resolve + }) + + const handle = await sandbox.files.watchDir(dirname, async (event) => { + if (event.type === FilesystemEventType.WRITE && event.name === filename) { + trigger() + } + }) + + await sandbox.files.write(`${dirname}/${filename}`, newContent) + + await eventPromise + + await handle.stop() +}) + +sandboxTest('watch recursive directory changes', async ({ sandbox }) => { + const dirname = 'test_recursive_watch_dir' + const nestedDirname = 'test_nested_watch_dir' + const filename = 'test_watch.txt' + const content = 'This file will be watched.' + const newContent = 'This file has been modified.' + + await sandbox.files.makeDir(`${dirname}/${nestedDirname}`) + if (isDebug) { + onTestFinished(() => sandbox.files.remove(dirname)) + } + + await sandbox.files.write(`${dirname}/${nestedDirname}/${filename}`, content) + + let trigger: () => void + + const eventPromise = new Promise((resolve) => { + trigger = resolve + }) + + const expectedFileName = `${nestedDirname}/${filename}` + const handle = await sandbox.files.watchDir( + dirname, + async (event) => { + if ( + event.type === FilesystemEventType.WRITE && + event.name === expectedFileName + ) { + trigger() + } + }, + { + recursive: true, + } + ) + + await sandbox.files.write( + `${dirname}/${nestedDirname}/${filename}`, + newContent + ) + + await eventPromise + + await handle.stop() +}) + +sandboxTest( + 'watch recursive directory after nested folder addition', + async ({ sandbox }) => { + const dirname = 'test_recursive_watch_dir_add' + const nestedDirname = 'test_nested_watch_dir' + const filename = 'test_watch.txt' + const content = 'This file will be watched.' + + await sandbox.files.makeDir(dirname) + if (isDebug) { + onTestFinished(() => sandbox.files.remove(dirname)) + } + + let triggerFile: () => void + let triggerFolder: () => void + + const eventFilePromise = new Promise((resolve) => { + triggerFile = resolve + }) + const eventFolderPromise = new Promise((resolve) => { + triggerFolder = resolve + }) + + const expectedFileName = `${nestedDirname}/${filename}` + const handle = await sandbox.files.watchDir( + dirname, + async (event) => { + if ( + event.type === FilesystemEventType.WRITE && + event.name === expectedFileName + ) { + triggerFile() + } else if ( + event.type === FilesystemEventType.CREATE && + event.name === nestedDirname + ) { + triggerFolder() + } + }, + { + recursive: true, + } + ) + + await sandbox.files.makeDir(`${dirname}/${nestedDirname}`) + await eventFolderPromise + + await sandbox.files.write( + `${dirname}/${nestedDirname}/${filename}`, + content + ) + await eventFilePromise + + await handle.stop() + } +) + +sandboxTest('watch directory changes with entry info', async ({ sandbox }) => { + const dirname = 'test_watch_dir_entry' + const filename = 'test_watch.txt' + const content = 'This file will be watched.' + const newContent = 'This file has been modified.' + + await sandbox.files.makeDir(dirname) + await sandbox.files.write(`${dirname}/${filename}`, content) + + let resolveEvent: (event: FilesystemEvent) => void + const eventPromise = new Promise((resolve) => { + resolveEvent = resolve + }) + + const handle = await sandbox.files.watchDir( + dirname, + async (event) => { + if (event.type === FilesystemEventType.WRITE && event.name === filename) { + resolveEvent(event) + } + }, + { includeEntry: true } + ) + + await sandbox.files.write(`${dirname}/${filename}`, newContent) + + const event = await eventPromise + + // The entry is populated best-effort for events where the path still exists. + expect(event.entry).toBeDefined() + expect(event.entry?.name).toBe(filename) + expect(event.entry?.path).toBe(`/home/user/${dirname}/${filename}`) + expect(event.entry?.type).toBe(FileType.FILE) + + await handle.stop() +}) + +sandboxTest( + 'watch directory changes with network mounts allowed', + async ({ sandbox }) => { + const dirname = 'test_watch_dir_network_mounts' + const filename = 'test_watch.txt' + const content = 'This file will be watched.' + const newContent = 'This file has been modified.' + + await sandbox.files.makeDir(dirname) + await sandbox.files.write(`${dirname}/${filename}`, content) + + let trigger: () => void + + const eventPromise = new Promise((resolve) => { + trigger = resolve + }) + + // The flag only lifts the network-mount restriction — watching a regular + // directory must work the same with it enabled. + const handle = await sandbox.files.watchDir( + dirname, + async (event) => { + if ( + event.type === FilesystemEventType.WRITE && + event.name === filename + ) { + trigger() + } + }, + { allowNetworkMounts: true } + ) + + await sandbox.files.write(`${dirname}/${filename}`, newContent) + + await eventPromise + + await handle.stop() + } +) + +sandboxTest('watch non-existing directory', async ({ sandbox }) => { + const dirname = 'non_existing_watch_dir' + + await expect(sandbox.files.watchDir(dirname, () => {})).rejects.toThrowError( + FileNotFoundError + ) +}) + +sandboxTest('watch file', async ({ sandbox }) => { + const filename = 'test_watch.txt' + const content = 'This file will be watched.' + await sandbox.files.write(filename, content) + + await expect(sandbox.files.watchDir(filename, () => {})).rejects.toThrowError( + SandboxError + ) +}) diff --git a/packages/js-sdk/tests/sandbox/files/watchHandle.test.ts b/packages/js-sdk/tests/sandbox/files/watchHandle.test.ts new file mode 100644 index 0000000..d4c4dc4 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/files/watchHandle.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest' + +import { EventType } from '../../../src/envd/filesystem/filesystem_pb' +import { + FilesystemEventType, + WatchHandle, +} from '../../../src/sandbox/filesystem/watchHandle' + +function filesystemEvent(name: string, type: EventType = EventType.WRITE) { + return { + event: { + case: 'filesystem' as const, + value: { name, type, entry: undefined }, + }, + } +} + +function events(items: ReturnType[]) { + async function* gen() { + for (const item of items) { + yield item as any + } + } + return gen() +} + +describe('WatchHandle', () => { + it('awaits an async onEvent before finishing and firing onExit', async () => { + let callbackStarted = false + let releaseCallback: (() => void) | undefined + const callbackBlocked = new Promise((resolve) => { + releaseCallback = resolve + }) + + const onEvent = async () => { + callbackStarted = true + await callbackBlocked + } + + let exitCalled = false + const onExit = () => { + exitCalled = true + } + + new WatchHandle( + () => {}, + events([filesystemEvent('a.txt')]), + onEvent, + onExit + ) + + await vi.waitFor(() => { + expect(callbackStarted).toBe(true) + }) + // onExit must not fire while onEvent is still pending. + expect(exitCalled).toBe(false) + + releaseCallback?.() + await vi.waitFor(() => { + expect(exitCalled).toBe(true) + }) + }) + + it('routes a rejecting async onEvent to onExit and stops the watch', async () => { + const error = new Error('callback failed') + + let stopped = false + let exitErr: Error | undefined | 'unset' = 'unset' + + new WatchHandle( + () => { + stopped = true + }, + events([filesystemEvent('a.txt')]), + async () => { + throw error + }, + (err) => { + exitErr = err ?? undefined + } + ) + + await vi.waitFor(() => { + expect(exitErr).not.toBe('unset') + }) + expect(exitErr).toBe(error) + expect(stopped).toBe(true) + }) + + it('calls onExit with no argument when the stream ends cleanly', async () => { + const received: FilesystemEventType[] = [] + let exitArgs: unknown[] | undefined + + new WatchHandle( + () => {}, + events([filesystemEvent('a.txt')]), + (event) => { + received.push(event.type) + }, + (...args: unknown[]) => { + exitArgs = args + } + ) + + await vi.waitFor(() => { + expect(exitArgs).toBeDefined() + }) + // No error is passed on a clean exit — onExit is called with zero args. + expect(exitArgs).toEqual([]) + expect(received).toEqual([FilesystemEventType.WRITE]) + }) + + it('does not let a throwing onExit become an unhandled rejection', async () => { + let stopped = false + + new WatchHandle( + () => { + stopped = true + }, + events([filesystemEvent('a.txt')]), + () => {}, + () => { + throw new Error('onExit failed') + } + ) + + // handleStop runs after onExit, so observing the stop confirms the loop + // ran the throwing onExit to completion. A leaked rejection would fail the + // test run instead. + await vi.waitFor(() => { + expect(stopped).toBe(true) + }) + }) +}) diff --git a/packages/js-sdk/tests/sandbox/files/write.test.ts b/packages/js-sdk/tests/sandbox/files/write.test.ts new file mode 100644 index 0000000..61f046c --- /dev/null +++ b/packages/js-sdk/tests/sandbox/files/write.test.ts @@ -0,0 +1,366 @@ +import path from 'path' +import { assert } from 'vitest' + +import { WriteEntry } from '../../../src/sandbox/filesystem' +import { isDebug, sandboxTest } from '../../setup.js' + +sandboxTest('write file', async ({ sandbox }) => { + const filename = 'test_write.txt' + const content = 'This is a test file.' + + // Attempt to write with undefined path and content + await sandbox.files + // @ts-ignore + .write(undefined, content) + .then((e) => { + assert.isUndefined(e) + }) + .catch((err) => { + assert.instanceOf(err, Error) + assert.include(err.message, 'Path or files are required') + }) + + const info = await sandbox.files.write(filename, content) + assert.isFalse(Array.isArray(info)) + assert.equal(info.name, filename) + assert.equal(info.type, 'file') + assert.equal(info.path, `/home/user/${filename}`) + + const exists = await sandbox.files.exists(filename) + assert.isTrue(exists) + const readContent = await sandbox.files.read(filename) + assert.equal(readContent, content) + + if (isDebug) { + await sandbox.files.remove(filename) + } +}) + +sandboxTest('write multiple files', async ({ sandbox }) => { + const numTestFiles = 10 + + // Attempt to write with empty files array + const emptyInfo = await sandbox.files.write([]) + assert.isTrue(Array.isArray(emptyInfo)) + assert.equal(emptyInfo.length, 0) + + // Attempt to write with undefined path and file array + await sandbox.files + // @ts-ignore + .write(undefined, [ + { path: 'one_test_file.txt', data: 'This is a test file.' }, + ]) + .then((e) => { + assert.isUndefined(e) + }) + .catch((err) => { + assert.instanceOf(err, Error) + assert.include(err.message, 'Path or files are required') + }) + + // Attempt to write with path and file array + await sandbox.files + // @ts-ignore + .write('/path/to/file', [ + { path: 'one_test_file.txt', data: 'This is a test file.' }, + ]) + .then((e) => { + assert.isUndefined(e) + }) + .catch((err) => { + assert.instanceOf(err, Error) + assert.include( + err.message, + 'Cannot specify both path and array of files. You have to specify either path and data for a single file or an array for multiple files.' + ) + }) + + // Attempt to write with one file in array + const info = await sandbox.files.write([ + { path: 'one_test_file.txt', data: 'This is a test file.' }, + ]) + assert.isTrue(Array.isArray(info)) + assert.equal(info[0].name, 'one_test_file.txt') + assert.equal(info[0].type, 'file') + assert.equal(info[0].path, '/home/user/one_test_file.txt') + + // Attempt to write with multiple files in array + const files: WriteEntry[] = [] + + for (let i = 0; i < numTestFiles; i++) { + let path = '' + if (i % 2 == 0) { + path = `/${i}/multi_test_file${i}.txt` + } else { + path = `/home/user/multi_test_file${i}.txt` + } + + files.push({ + path: path, + data: `This is a test file ${i}.`, + }) + } + + const infos = await sandbox.files.write(files) + + assert.isTrue(Array.isArray(infos)) + assert.equal(infos.length, files.length) + + // Attempt to write with multiple files in array + for (let i = 0; i < files.length; i++) { + const file = files[i] + const info = infos[i] + + assert.equal(info.name, path.basename(file.path)) + assert.equal(info.path, file.path) + assert.equal(info.type, 'file') + + const exists = await sandbox.files.exists(file.path) + assert.isTrue(exists) + const readContent = await sandbox.files.read(file.path) + assert.equal(readContent, file.data) + } + + if (isDebug) { + for (const file of files) { + await sandbox.files.remove(file.path) + } + } +}) + +sandboxTest('write file', async ({ sandbox }) => { + const filename = 'test_write.txt' + const content = 'This is a test file.' + + const info = await sandbox.files.write(filename, content) + assert.isFalse(Array.isArray(info)) + assert.equal(info.name, filename) + assert.equal(info.type, 'file') + assert.equal(info.path, `/home/user/${filename}`) + + const exists = await sandbox.files.exists(filename) + assert.isTrue(exists) + const readContent = await sandbox.files.read(filename) + assert.equal(readContent, content) + + if (isDebug) { + await sandbox.files.remove(filename) + } +}) + +sandboxTest('overwrite file', async ({ sandbox }) => { + const filename = 'test_overwrite.txt' + const initialContent = 'Initial content.' + const newContent = 'New content.' + + await sandbox.files.write(filename, initialContent) + await sandbox.files.write(filename, newContent) + const readContent = await sandbox.files.read(filename) + assert.equal(readContent, newContent) + + if (isDebug) { + await sandbox.files.remove(filename) + } +}) + +sandboxTest('write to non-existing directory', async ({ sandbox }) => { + const filename = 'non_existing_dir/test_write.txt' + const content = 'This should succeed too.' + + await sandbox.files.write(filename, content) + const exists = await sandbox.files.exists(filename) + assert.isTrue(exists) + const readContent = await sandbox.files.read(filename) + assert.equal(readContent, content) + + if (isDebug) { + await sandbox.files.remove(filename) + } +}) + +sandboxTest('writeFiles with empty array', async ({ sandbox }) => { + const emptyInfo = await sandbox.files.writeFiles([]) + assert.isTrue(Array.isArray(emptyInfo)) + assert.equal(emptyInfo.length, 0) +}) + +sandboxTest('writeFiles with multiple files', async ({ sandbox }) => { + const numTestFiles = 10 + const files: WriteEntry[] = [] + + for (let i = 0; i < numTestFiles; i++) { + const filePath = `writefiles_test_${i}.txt` + + files.push({ + path: filePath, + data: `This is a test file ${i}.`, + }) + } + + const infos = await sandbox.files.writeFiles(files) + + assert.isTrue(Array.isArray(infos)) + assert.equal(infos.length, files.length) + + for (let i = 0; i < files.length; i++) { + const file = files[i] + const info = infos[i] + + assert.equal(info.name, path.basename(file.path)) + assert.equal(info.path, `/home/user/${file.path}`) + assert.equal(info.type, 'file') + + const exists = await sandbox.files.exists(info.path) + assert.isTrue(exists) + + const readContent = await sandbox.files.read(info.path) + assert.equal(readContent, file.data) + } + + if (isDebug) { + for (const file of files) { + await sandbox.files.remove(file.path) + } + } +}) + +sandboxTest('writeFiles with different data types', async ({ sandbox }) => { + const textData = 'Text string data' + const arrayBufferData = new TextEncoder().encode('ArrayBuffer data').buffer + const blobData = new Blob(['Blob data'], { type: 'text/plain' }) + const streamContent = 'ReadableStream data' + const encoder = new TextEncoder() + const streamData = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(streamContent)) + controller.close() + }, + }) + + const files: WriteEntry[] = [ + { path: 'writefiles_text.txt', data: textData }, + { path: 'writefiles_arraybuffer.txt', data: arrayBufferData }, + { path: 'writefiles_blob.txt', data: blobData }, + { path: 'writefiles_stream.txt', data: streamData }, + ] + + const infos = await sandbox.files.writeFiles(files) + + assert.equal(infos.length, 4) + + // Verify text file + const textContent = await sandbox.files.read('writefiles_text.txt') + assert.equal(textContent, textData) + + // Verify ArrayBuffer file + const arrayBufferContent = await sandbox.files.read( + 'writefiles_arraybuffer.txt' + ) + assert.equal(arrayBufferContent, 'ArrayBuffer data') + + // Verify Blob file + const blobContent = await sandbox.files.read('writefiles_blob.txt') + assert.equal(blobContent, 'Blob data') + + // Verify ReadableStream file + const streamFileContent = await sandbox.files.read('writefiles_stream.txt') + assert.equal(streamFileContent, streamContent) + + if (isDebug) { + for (const file of files) { + await sandbox.files.remove(file.path) + } + } +}) + +sandboxTest('writeFiles creates parent directories', async ({ sandbox }) => { + const files: WriteEntry[] = [ + { + path: 'writefiles_nested_dir/nested/file1.txt', + data: 'Content in nested directory', + }, + ] + + const infos = await sandbox.files.writeFiles(files) + + assert.equal(infos.length, 1) + assert.equal( + infos[0].path, + '/home/user/writefiles_nested_dir/nested/file1.txt' + ) + + const exists = await sandbox.files.exists(infos[0].path) + assert.isTrue(exists) + + const content = await sandbox.files.read(infos[0].path) + assert.equal(content, 'Content in nested directory') + + if (isDebug) { + await sandbox.files.remove(files[0].path) + } +}) + +sandboxTest('writeFiles overwrites existing files', async ({ sandbox }) => { + const filename = 'writefiles_overwrite.txt' + const initialContent = 'Initial content' + const newContent = 'New content' + + // Write initial file + await sandbox.files.writeFiles([{ path: filename, data: initialContent }]) + + let readContent = await sandbox.files.read(filename) + assert.equal(readContent, initialContent) + + // Overwrite with new content + await sandbox.files.writeFiles([{ path: filename, data: newContent }]) + + readContent = await sandbox.files.read(filename) + assert.equal(readContent, newContent) + + if (isDebug) { + await sandbox.files.remove(filename) + } +}) + +sandboxTest( + 'write ReadableStream with octet stream upload', + async ({ sandbox }) => { + const filename = 'test_write_octet_stream.bin' + const content = 'Streamed octet-stream upload. '.repeat(10_000) + const stream = new Blob([content]).stream() + + const info = await sandbox.files.write(filename, stream, { + useOctetStream: true, + }) + assert.equal(info.path, `/home/user/${filename}`) + + const readContent = await sandbox.files.read(filename) + assert.equal(readContent, content) + + if (isDebug) { + await sandbox.files.remove(filename) + } + } +) + +sandboxTest( + 'write ReadableStream with octet stream upload and gzip', + async ({ sandbox }) => { + const filename = 'test_write_octet_stream_gzip.bin' + const content = 'Streamed gzipped octet-stream upload. '.repeat(10_000) + const stream = new Blob([content]).stream() + + const info = await sandbox.files.write(filename, stream, { + useOctetStream: true, + gzip: true, + }) + assert.equal(info.path, `/home/user/${filename}`) + + const readContent = await sandbox.files.read(filename) + assert.equal(readContent, content) + + if (isDebug) { + await sandbox.files.remove(filename) + } + } +) diff --git a/packages/js-sdk/tests/sandbox/git/add.test.ts b/packages/js-sdk/tests/sandbox/git/add.test.ts new file mode 100644 index 0000000..4336c39 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/add.test.ts @@ -0,0 +1,24 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { cleanupBaseDir, createBaseDir, createRepo } from './helpers.js' + +sandboxTest('git add stages files', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + await sandbox.files.write(`${repoPath}/README.md`, 'hello\n') + + await sandbox.git.add(repoPath) + + const status = await sandbox.git.status(repoPath) + const entry = status.fileStatus.find( + (file: any) => file.name === 'README.md' + ) + expect(entry?.status).toBe('added') + expect(entry?.staged).toBe(true) + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) diff --git a/packages/js-sdk/tests/sandbox/git/branches.test.ts b/packages/js-sdk/tests/sandbox/git/branches.test.ts new file mode 100644 index 0000000..f3ed328 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/branches.test.ts @@ -0,0 +1,82 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + cleanupBaseDir, + createBaseDir, + createRepoWithCommit, +} from './helpers.js' + +sandboxTest('git branches lists current and feature', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + await sandbox.commands.run(`git -C "${repoPath}" branch feature`) + + const branches = await sandbox.git.branches(repoPath) + expect(branches.currentBranch).toBe('main') + expect(branches.branches).toContain('main') + expect(branches.branches).toContain('feature') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest('git checkoutBranch switches branch', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + await sandbox.commands.run(`git -C "${repoPath}" branch feature`) + + await sandbox.git.checkoutBranch(repoPath, 'feature') + + const head = ( + await sandbox.commands.run( + `git -C "${repoPath}" rev-parse --abbrev-ref HEAD` + ) + ).stdout.trim() + expect(head).toBe('feature') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest( + 'git createBranch creates and checks out branch', + async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + await sandbox.git.createBranch(repoPath, 'feature') + + const branches = await sandbox.git.branches(repoPath) + expect(branches.branches).toContain('feature') + expect(branches.currentBranch).toBe('feature') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } + } +) + +sandboxTest('git deleteBranch removes branch', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + await sandbox.commands.run(`git -C "${repoPath}" branch feature`) + + await sandbox.git.deleteBranch(repoPath, 'feature') + + const branch = ( + await sandbox.commands.run(`git -C "${repoPath}" branch --list feature`) + ).stdout.trim() + const branches = await sandbox.git.branches(repoPath) + expect(branch).toBe('') + expect(branches.branches).not.toContain('feature') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) diff --git a/packages/js-sdk/tests/sandbox/git/clone.test.ts b/packages/js-sdk/tests/sandbox/git/clone.test.ts new file mode 100644 index 0000000..d09cac8 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/clone.test.ts @@ -0,0 +1,35 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + cleanupBaseDir, + createBaseDir, + createRepoWithCommit, + startGitDaemon, +} from './helpers.js' + +sandboxTest('git clone fetches repo', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + const daemon = await startGitDaemon(sandbox, baseDir) + const clonePath = `${baseDir}/clone` + + try { + await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl) + await sandbox.git.push(repoPath, { + remote: 'origin', + branch: 'main', + }) + + await sandbox.git.clone(daemon.remoteUrl, { path: clonePath }) + const contents = await sandbox.files.read(`${clonePath}/README.md`) + expect(contents).toContain('hello') + } finally { + await daemon.handle.kill() + } + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) diff --git a/packages/js-sdk/tests/sandbox/git/commit.test.ts b/packages/js-sdk/tests/sandbox/git/commit.test.ts new file mode 100644 index 0000000..5e97302 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/commit.test.ts @@ -0,0 +1,66 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + AUTHOR_EMAIL, + AUTHOR_NAME, + cleanupBaseDir, + createBaseDir, + createRepo, +} from './helpers.js' + +sandboxTest('git commit creates commit', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + await sandbox.files.write(`${repoPath}/README.md`, 'hello\n') + await sandbox.git.add(repoPath) + + await sandbox.git.commit(repoPath, 'Initial commit', { + authorName: AUTHOR_NAME, + authorEmail: AUTHOR_EMAIL, + }) + + const message = ( + await sandbox.commands.run(`git -C "${repoPath}" log -1 --pretty=%B`) + ).stdout.trim() + expect(message).toBe('Initial commit') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest( + 'git commit uses config for missing author fields', + async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + await sandbox.commands.run( + `git -C "${repoPath}" config --local user.email "${AUTHOR_EMAIL}"` + ) + + await sandbox.files.write(`${repoPath}/README.md`, 'hello\n') + await sandbox.git.add(repoPath) + + const overrideName = 'Override Bot' + await sandbox.git.commit(repoPath, 'Partial author commit', { + authorName: overrideName, + }) + + const authorName = ( + await sandbox.commands.run(`git -C "${repoPath}" log -1 --pretty=%an`) + ).stdout.trim() + const authorEmail = ( + await sandbox.commands.run(`git -C "${repoPath}" log -1 --pretty=%ae`) + ).stdout.trim() + + expect(authorName).toBe(overrideName) + expect(authorEmail).toBe(AUTHOR_EMAIL) + } finally { + await cleanupBaseDir(sandbox, baseDir) + } + } +) diff --git a/packages/js-sdk/tests/sandbox/git/config.test.ts b/packages/js-sdk/tests/sandbox/git/config.test.ts new file mode 100644 index 0000000..4170715 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/config.test.ts @@ -0,0 +1,87 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + AUTHOR_EMAIL, + AUTHOR_NAME, + cleanupBaseDir, + createBaseDir, + createRepo, +} from './helpers.js' + +sandboxTest('git getConfig reads local config', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + await sandbox.commands.run( + `git -C "${repoPath}" config --local pull.rebase true` + ) + + const value = await sandbox.git.getConfig('pull.rebase', { + scope: 'local', + path: repoPath, + }) + const commandValue = ( + await sandbox.commands.run( + `git -C "${repoPath}" config --local --get pull.rebase` + ) + ).stdout.trim() + expect(value).toBe('true') + expect(commandValue).toBe('true') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest('git setConfig updates local config', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + + await sandbox.git.setConfig('pull.rebase', 'true', { + scope: 'local', + path: repoPath, + }) + + const value = ( + await sandbox.commands.run( + `git -C "${repoPath}" config --local --get pull.rebase` + ) + ).stdout.trim() + const configuredValue = await sandbox.git.getConfig('pull.rebase', { + scope: 'local', + path: repoPath, + }) + expect(value).toBe('true') + expect(configuredValue).toBe('true') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest( + 'git configureUser sets global user config', + async ({ sandbox }) => { + await sandbox.git.configureUser(AUTHOR_NAME, AUTHOR_EMAIL) + + const name = ( + await sandbox.commands.run('git config --global --get user.name') + ).stdout.trim() + const email = ( + await sandbox.commands.run('git config --global --get user.email') + ).stdout.trim() + const configuredName = await sandbox.git.getConfig('user.name', { + scope: 'global', + }) + const configuredEmail = await sandbox.git.getConfig('user.email', { + scope: 'global', + }) + + expect(name).toBe(AUTHOR_NAME) + expect(email).toBe(AUTHOR_EMAIL) + expect(configuredName).toBe(AUTHOR_NAME) + expect(configuredEmail).toBe(AUTHOR_EMAIL) + } +) diff --git a/packages/js-sdk/tests/sandbox/git/dangerouslyAuthenticate.test.ts b/packages/js-sdk/tests/sandbox/git/dangerouslyAuthenticate.test.ts new file mode 100644 index 0000000..1c6c382 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/dangerouslyAuthenticate.test.ts @@ -0,0 +1,22 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { HOST, PASSWORD, PROTOCOL, USERNAME } from './helpers.js' + +sandboxTest('git dangerouslyAuthenticate sets helper', async ({ sandbox }) => { + await sandbox.git.dangerouslyAuthenticate({ + username: USERNAME, + password: PASSWORD, + host: HOST, + protocol: PROTOCOL, + }) + + const helper = ( + await sandbox.commands.run('git config --global --get credential.helper') + ).stdout.trim() + const configuredHelper = await sandbox.git.getConfig('credential.helper', { + scope: 'global', + }) + expect(helper).toBe('store') + expect(configuredHelper).toBe('store') +}) diff --git a/packages/js-sdk/tests/sandbox/git/helpers.ts b/packages/js-sdk/tests/sandbox/git/helpers.ts new file mode 100644 index 0000000..797682f --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/helpers.ts @@ -0,0 +1,57 @@ +import { randomUUID } from 'node:crypto' + +export const AUTHOR_NAME = 'Sandbox Bot' +export const AUTHOR_EMAIL = 'sandbox@example.com' +export const USERNAME = 'git' +export const PASSWORD = 'token' +export const HOST = 'example.com' +export const PROTOCOL = 'https' + +const BASE_DIR = '/tmp/test-git' + +export async function createBaseDir(sandbox: any) { + const baseDir = `${BASE_DIR}/${randomUUID()}` + await sandbox.commands.run(`rm -rf "${baseDir}" && mkdir -p "${baseDir}"`) + return baseDir +} + +export async function cleanupBaseDir(sandbox: any, baseDir: string) { + await sandbox.commands.run(`rm -rf "${baseDir}"`) +} + +export async function createRepo(sandbox: any, baseDir: string) { + const repoPath = `${baseDir}/repo` + await sandbox.git.init(repoPath, { initialBranch: 'main' }) + return repoPath +} + +export async function createRepoWithCommit(sandbox: any, baseDir: string) { + const repoPath = await createRepo(sandbox, baseDir) + await sandbox.files.write(`${repoPath}/README.md`, 'hello\n') + await sandbox.git.add(repoPath) + await sandbox.git.commit(repoPath, 'Initial commit', { + authorName: AUTHOR_NAME, + authorEmail: AUTHOR_EMAIL, + }) + return repoPath +} + +export async function startGitDaemon(sandbox: any, baseDir: string) { + const remotePath = `${baseDir}/remote.git` + await sandbox.commands.run( + `git init --bare --initial-branch=main "${remotePath}"` + ) + const port = 9418 + Math.floor(Math.random() * 1000) + const handle = await sandbox.commands.run( + `git daemon --reuseaddr --base-path="${baseDir}" --export-all ` + + `--enable=receive-pack --informative-errors --listen=127.0.0.1 --port=${port}`, + { background: true } + ) + await sandbox.commands.run('sleep 1') + return { + handle, + remotePath, + remoteUrl: `git://127.0.0.1:${port}/remote.git`, + port, + } +} diff --git a/packages/js-sdk/tests/sandbox/git/init.test.ts b/packages/js-sdk/tests/sandbox/git/init.test.ts new file mode 100644 index 0000000..dfc2949 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/init.test.ts @@ -0,0 +1,24 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { cleanupBaseDir, createBaseDir } from './helpers.js' + +sandboxTest('git init', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = `${baseDir}/repo` + + await sandbox.git.init(repoPath, { initialBranch: 'main' }) + + expect(await sandbox.files.exists(`${repoPath}/.git`)).toBe(true) + const head = ( + await sandbox.commands.run( + `git -C "${repoPath}" symbolic-ref --short HEAD` + ) + ).stdout.trim() + expect(head).toBe('main') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) diff --git a/packages/js-sdk/tests/sandbox/git/remote.test.ts b/packages/js-sdk/tests/sandbox/git/remote.test.ts new file mode 100644 index 0000000..fdf6fc4 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/remote.test.ts @@ -0,0 +1,82 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + cleanupBaseDir, + createBaseDir, + createRepo, + startGitDaemon, +} from './helpers.js' + +sandboxTest( + 'git remoteGet returns undefined for missing remote', + async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + const missingUrl = await sandbox.git.remoteGet(repoPath, 'origin') + expect(missingUrl).toBeUndefined() + } finally { + await cleanupBaseDir(sandbox, baseDir) + } + } +) + +sandboxTest('git remoteAdd adds remote', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + const daemon = await startGitDaemon(sandbox, baseDir) + + try { + await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl) + const remoteUrl = await sandbox.git.remoteGet(repoPath, 'origin') + expect(remoteUrl).toBe(daemon.remoteUrl) + } finally { + await daemon.handle.kill() + } + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest('git remoteAdd overwrites existing remote', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + const daemon = await startGitDaemon(sandbox, baseDir) + + try { + await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl) + const currentUrl = ( + await sandbox.commands.run(`git -C "${repoPath}" remote get-url origin`) + ).stdout.trim() + const currentRemote = await sandbox.git.remoteGet(repoPath, 'origin') + expect(currentUrl).toBe(daemon.remoteUrl) + expect(currentRemote).toBe(daemon.remoteUrl) + + const secondPath = `${baseDir}/remote-2.git` + await sandbox.commands.run( + `git init --bare --initial-branch=main "${secondPath}"` + ) + const secondUrl = `git://127.0.0.1:${daemon.port}/remote-2.git` + + await sandbox.git.remoteAdd(repoPath, 'origin', secondUrl, { + overwrite: true, + }) + const updatedUrl = ( + await sandbox.commands.run(`git -C "${repoPath}" remote get-url origin`) + ).stdout.trim() + const updatedRemote = await sandbox.git.remoteGet(repoPath, 'origin') + expect(updatedUrl).toBe(secondUrl) + expect(updatedRemote).toBe(secondUrl) + } finally { + await daemon.handle.kill() + } + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) diff --git a/packages/js-sdk/tests/sandbox/git/reset.test.ts b/packages/js-sdk/tests/sandbox/git/reset.test.ts new file mode 100644 index 0000000..daf8bcd --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/reset.test.ts @@ -0,0 +1,30 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + cleanupBaseDir, + createBaseDir, + createRepoWithCommit, +} from './helpers.js' + +sandboxTest('git reset --hard discards changes', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + await sandbox.files.write(`${repoPath}/README.md`, 'changed\n') + + const status = await sandbox.git.status(repoPath) + expect(status.isClean).toBe(false) + + await sandbox.git.reset(repoPath, { mode: 'hard', target: 'HEAD' }) + + const statusAfter = await sandbox.git.status(repoPath) + expect(statusAfter.isClean).toBe(true) + + const contents = await sandbox.files.read(`${repoPath}/README.md`) + expect(contents).toBe('hello\n') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) diff --git a/packages/js-sdk/tests/sandbox/git/restore.test.ts b/packages/js-sdk/tests/sandbox/git/restore.test.ts new file mode 100644 index 0000000..2d03484 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/restore.test.ts @@ -0,0 +1,58 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + cleanupBaseDir, + createBaseDir, + createRepoWithCommit, +} from './helpers.js' + +sandboxTest('git restore --staged unstages changes', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + await sandbox.files.write(`${repoPath}/README.md`, 'changed\n') + await sandbox.git.add(repoPath, { files: ['README.md'] }) + + const status = await sandbox.git.status(repoPath) + expect(status.hasStaged).toBe(true) + + await sandbox.git.restore(repoPath, { + paths: ['README.md'], + staged: true, + worktree: false, + }) + + const statusAfter = await sandbox.git.status(repoPath) + expect(statusAfter.hasStaged).toBe(false) + expect(statusAfter.hasChanges).toBe(true) + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest( + 'git restore discards working tree changes', + async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + await sandbox.files.write(`${repoPath}/README.md`, 'changed\n') + + const status = await sandbox.git.status(repoPath) + expect(status.isClean).toBe(false) + + await sandbox.git.restore(repoPath, { paths: ['README.md'] }) + + const statusAfter = await sandbox.git.status(repoPath) + expect(statusAfter.isClean).toBe(true) + + const contents = await sandbox.files.read(`${repoPath}/README.md`) + expect(contents).toBe('hello\n') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } + } +) diff --git a/packages/js-sdk/tests/sandbox/git/status.test.ts b/packages/js-sdk/tests/sandbox/git/status.test.ts new file mode 100644 index 0000000..bbae949 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/status.test.ts @@ -0,0 +1,99 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + AUTHOR_EMAIL, + AUTHOR_NAME, + cleanupBaseDir, + createBaseDir, + createRepo, +} from './helpers.js' + +sandboxTest('git status reports untracked file', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + await sandbox.files.write(`${repoPath}/README.md`, 'hello\n') + + const status = await sandbox.git.status(repoPath) + const entry = status.fileStatus.find( + (file: any) => file.name === 'README.md' + ) + expect(entry?.status).toBe('untracked') + expect(status.isClean).toBe(false) + expect(status.hasChanges).toBe(true) + expect(status.hasUntracked).toBe(true) + expect(status.hasStaged).toBe(false) + expect(status.hasConflicts).toBe(false) + expect(status.totalCount).toBe(1) + expect(status.stagedCount).toBe(0) + expect(status.unstagedCount).toBe(1) + expect(status.untrackedCount).toBe(1) + expect(status.conflictCount).toBe(0) + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest( + 'git status reports added modified deleted renamed', + async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + await sandbox.files.write(`${repoPath}/README.md`, 'hello\n') + await sandbox.files.write(`${repoPath}/DELETE.md`, 'delete me\n') + await sandbox.files.write(`${repoPath}/RENAME.md`, 'rename me\n') + await sandbox.git.add(repoPath) + await sandbox.git.commit(repoPath, 'Initial commit', { + authorName: AUTHOR_NAME, + authorEmail: AUTHOR_EMAIL, + }) + + await sandbox.files.write(`${repoPath}/README.md`, 'hello again\n') + await sandbox.files.write(`${repoPath}/NEW.md`, 'new file\n') + await sandbox.git.add(repoPath, { files: ['NEW.md'] }) + await sandbox.commands.run(`git -C "${repoPath}" rm DELETE.md`) + await sandbox.commands.run(`git -C "${repoPath}" mv RENAME.md RENAMED.md`) + + const status = await sandbox.git.status(repoPath) + + const modified = status.fileStatus.find( + (file: any) => file.name === 'README.md' + ) + const added = status.fileStatus.find( + (file: any) => file.name === 'NEW.md' + ) + const deleted = status.fileStatus.find( + (file: any) => file.name === 'DELETE.md' + ) + const renamed = status.fileStatus.find( + (file: any) => file.name === 'RENAMED.md' + ) + + expect(modified?.status).toBe('modified') + expect(modified?.staged).toBe(false) + expect(added?.status).toBe('added') + expect(added?.staged).toBe(true) + expect(deleted?.status).toBe('deleted') + expect(deleted?.staged).toBe(true) + expect(renamed?.status).toBe('renamed') + expect(renamed?.staged).toBe(true) + expect(renamed?.renamedFrom).toBe('RENAME.md') + + expect(status.hasChanges).toBe(true) + expect(status.hasStaged).toBe(true) + expect(status.hasUntracked).toBe(false) + expect(status.hasConflicts).toBe(false) + expect(status.totalCount).toBe(4) + expect(status.stagedCount).toBe(3) + expect(status.unstagedCount).toBe(1) + expect(status.untrackedCount).toBe(0) + expect(status.conflictCount).toBe(0) + } finally { + await cleanupBaseDir(sandbox, baseDir) + } + } +) diff --git a/packages/js-sdk/tests/sandbox/git/sync.test.ts b/packages/js-sdk/tests/sandbox/git/sync.test.ts new file mode 100644 index 0000000..f51b5ef --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/sync.test.ts @@ -0,0 +1,116 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + AUTHOR_EMAIL, + AUTHOR_NAME, + cleanupBaseDir, + createBaseDir, + createRepoWithCommit, + startGitDaemon, +} from './helpers.js' + +sandboxTest('git push updates remote', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + const daemon = await startGitDaemon(sandbox, baseDir) + + try { + await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl) + await sandbox.git.push(repoPath, { + remote: 'origin', + branch: 'main', + }) + + const message = ( + await sandbox.commands.run( + `git --git-dir="${daemon.remotePath}" log -1 --pretty=%B` + ) + ).stdout.trim() + expect(message).toBe('Initial commit') + } finally { + await daemon.handle.kill() + } + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest('git push warns when no upstream', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + const daemon = await startGitDaemon(sandbox, baseDir) + + try { + await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl) + + await expect( + sandbox.git.push(repoPath, { setUpstream: false }) + ).rejects.toThrow(/no upstream branch is configured/i) + } finally { + await daemon.handle.kill() + } + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest('git pull updates clone', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + const daemon = await startGitDaemon(sandbox, baseDir) + const clonePath = `${baseDir}/clone` + + try { + await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl) + await sandbox.git.push(repoPath, { + remote: 'origin', + branch: 'main', + }) + await sandbox.git.clone(daemon.remoteUrl, { path: clonePath }) + + await sandbox.files.write(`${repoPath}/README.md`, 'hello\nmore\n') + await sandbox.git.add(repoPath) + await sandbox.git.commit(repoPath, 'Update README', { + authorName: AUTHOR_NAME, + authorEmail: AUTHOR_EMAIL, + }) + await sandbox.git.push(repoPath) + + await sandbox.git.pull(clonePath) + const contents = await sandbox.files.read(`${clonePath}/README.md`) + expect(contents).toContain('more') + } finally { + await daemon.handle.kill() + } + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest('git pull warns when no upstream', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + const daemon = await startGitDaemon(sandbox, baseDir) + + try { + await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl) + + await expect(sandbox.git.pull(repoPath)).rejects.toThrow( + /no upstream branch is configured/i + ) + } finally { + await daemon.handle.kill() + } + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) diff --git a/packages/js-sdk/tests/sandbox/git/validation.test.ts b/packages/js-sdk/tests/sandbox/git/validation.test.ts new file mode 100644 index 0000000..24bd2a9 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/validation.test.ts @@ -0,0 +1,44 @@ +import { test, expect } from 'vitest' + +import { Git } from '../../../src/sandbox/git' +import type { Commands } from '../../../src/sandbox/commands' +import { InvalidArgumentError } from '../../../src/errors' + +// Stub command runner that fails if a git command is actually executed — +// validation must throw before reaching it. +const failingCommands = { + run: () => { + throw new Error('commands.run should not be called') + }, +} as unknown as Commands + +test('git.reset throws InvalidArgumentError on an invalid mode', async () => { + const git = new Git(failingCommands) + await expect( + // @ts-expect-error - testing runtime validation with an invalid mode + git.reset('/repo', { mode: 'bogus' }) + ).rejects.toThrow(InvalidArgumentError) +}) + +test('git.reset accepts a valid mode', async () => { + const git = new Git(failingCommands) + // A valid mode must pass validation and reach the (stubbed) command runner. + await expect(git.reset('/repo', { mode: 'hard' })).rejects.toThrow( + 'commands.run should not be called' + ) +}) + +test('git.remoteAdd throws InvalidArgumentError when name or url is missing', async () => { + const git = new Git(failingCommands) + await expect( + git.remoteAdd('/repo', '', 'https://example.com') + ).rejects.toThrow(InvalidArgumentError) + await expect(git.remoteAdd('/repo', 'origin', '')).rejects.toThrow( + InvalidArgumentError + ) +}) + +test('git.remoteGet throws InvalidArgumentError when name is missing', async () => { + const git = new Git(failingCommands) + await expect(git.remoteGet('/repo', '')).rejects.toThrow(InvalidArgumentError) +}) diff --git a/packages/js-sdk/tests/sandbox/host.test.ts b/packages/js-sdk/tests/sandbox/host.test.ts new file mode 100644 index 0000000..37e45f6 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/host.test.ts @@ -0,0 +1,63 @@ +import { assert } from 'vitest' + +import { isDebug, sandboxTest, wait } from '../setup.js' +import { catchCmdExitErrorInBackground } from '../cmdHelper.js' +sandboxTest( + 'ping server in running sandbox', + async ({ sandbox }) => { + const cmd = await sandbox.commands.run('python -m http.server 8000', { + background: true, + }) + + const disable = catchCmdExitErrorInBackground(cmd) + + try { + await wait(1000) + + const host = sandbox.getHost(8000) + + let res = await fetch(`${isDebug ? 'http' : 'https'}://${host}`) + + for (let i = 0; i < 20; i++) { + if (res.status === 200) { + break + } + + res = await fetch(`${isDebug ? 'http' : 'https'}://${host}`) + await wait(500) + } + assert.equal(res.status, 200) + disable() + } finally { + try { + await cmd.kill() + } catch (e) { + console.error(e) + } + } + }, + 60_000 +) + +sandboxTest.skipIf(isDebug)( + 'ping server in non-running sandbox', + async ({ sandbox }) => { + const host = sandbox.getHost(3000) + const url = `https://${host}` + + await sandbox.kill() + + const res = await fetch(url) + assert.equal(res.status, 502) + + const text = await res.text() + const json = JSON.parse(text) as { + message: string + sandboxId: string + code: number + } + assert.equal(json.message, 'The sandbox was not found') + assert.isTrue(sandbox.sandboxId.startsWith(json.sandboxId)) + assert.equal(json.code, 502) + } +) diff --git a/packages/js-sdk/tests/sandbox/internetAccess.test.ts b/packages/js-sdk/tests/sandbox/internetAccess.test.ts new file mode 100644 index 0000000..498d3e4 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/internetAccess.test.ts @@ -0,0 +1,64 @@ +import { assert, describe } from 'vitest' + +import { CommandExitError } from '../../src' +import { sandboxTest, isDebug } from '../setup.js' + +describe('internet access enabled', () => { + sandboxTest.scoped({ + sandboxOpts: { + allowInternetAccess: true, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'internet access enabled', + async ({ sandbox }) => { + // Test internet connectivity by making a curl request to a reliable external site + const result = await sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://connectivitycheck.gstatic.com/generate_204" + ) + assert.equal(result.exitCode, 0) + assert.equal(result.stdout.trim(), '204') + } + ) +}) + +describe('internet access disabled', () => { + sandboxTest.scoped({ + sandboxOpts: { + allowInternetAccess: false, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'internet access disabled', + async ({ sandbox }) => { + // Test that internet connectivity is blocked by making a curl request + try { + await sandbox.commands.run( + 'curl --connect-timeout 3 --max-time 5 -Is https://connectivitycheck.gstatic.com/generate_204' + ) + // If we reach here, the command succeeded, which means internet access is not properly disabled + assert.fail('Expected command to fail when internet access is disabled') + } catch (error) { + // The command should fail or timeout when internet access is disabled + assert.isTrue(error instanceof CommandExitError) + assert.notEqual(error.exitCode, 0) + } + } + ) +}) + +describe('internet access default', () => { + sandboxTest.skipIf(isDebug)( + 'internet access default', + async ({ sandbox }) => { + // Test internet connectivity by making a curl request to a reliable external site + const result = await sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://connectivitycheck.gstatic.com/generate_204" + ) + assert.equal(result.exitCode, 0) + assert.equal(result.stdout.trim(), '204') + } + ) +}) diff --git a/packages/js-sdk/tests/sandbox/kill.test.ts b/packages/js-sdk/tests/sandbox/kill.test.ts new file mode 100644 index 0000000..6cb9c69 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/kill.test.ts @@ -0,0 +1,16 @@ +import { expect } from 'vitest' + +import { Sandbox } from '../../src' +import { sandboxTest, isDebug } from '../setup.js' + +sandboxTest.skipIf(isDebug)('kill', async ({ sandbox, sandboxTestId }) => { + const killed = await sandbox.kill() + expect(killed).toBe(true) + + const paginator = Sandbox.list({ + query: { state: ['running'], metadata: { sandboxTestId } }, + }) + const sandboxes = await paginator.nextItems() + + expect(sandboxes.map((s) => s.sandboxId)).not.toContain(sandbox.sandboxId) +}) diff --git a/packages/js-sdk/tests/sandbox/lifecyclePayload.test.ts b/packages/js-sdk/tests/sandbox/lifecyclePayload.test.ts new file mode 100644 index 0000000..2f8628c --- /dev/null +++ b/packages/js-sdk/tests/sandbox/lifecyclePayload.test.ts @@ -0,0 +1,139 @@ +import { assert, expect, test } from 'vitest' + +import { InvalidArgumentError, Sandbox } from '../../src' +import { isDebug, template, wait } from '../setup.js' + +test.skipIf(isDebug)( + 'filesystem-only auto-pause cannot be combined with auto-resume', + async () => { + // A filesystem-only auto-pause snapshot can only be resumed explicitly, so + // keepMemory:false with autoResume is rejected client-side. + await expect( + Sandbox.create(template, { + timeoutMs: 3_000, + lifecycle: { + onTimeout: { action: 'pause', keepMemory: false }, + autoResume: true, + }, + }) + ).rejects.toThrowError(InvalidArgumentError) + } +) + +test.skipIf(isDebug)( + 'keepMemory is not allowed when onTimeout action is kill', + async () => { + // The discriminated union forbids keepMemory on `action: 'kill'` at compile + // time (asserted by @ts-expect-error). The runtime guard below additionally + // rejects it for untyped (JS) callers that bypass the type. + await expect( + Sandbox.create(template, { + timeoutMs: 3_000, + lifecycle: { + // @ts-expect-error keepMemory is not allowed with action: 'kill' + onTimeout: { action: 'kill', keepMemory: false }, + }, + }) + ).rejects.toThrowError(InvalidArgumentError) + } +) + +test.skipIf(isDebug)( + 'auto-pause without auto-resume requires connect to wake', + async () => { + const sandbox = await Sandbox.create(template, { + timeoutMs: 3_000, + lifecycle: { + onTimeout: 'pause', + autoResume: false, + }, + }) + + try { + await wait(5_000) + + assert.equal((await sandbox.getInfo()).state, 'paused') + assert.isFalse(await sandbox.isRunning()) + + await sandbox.connect() + + assert.equal((await sandbox.getInfo()).state, 'running') + assert.isTrue(await sandbox.isRunning()) + } finally { + await sandbox.kill().catch(() => {}) + } + }, + 60_000 +) + +test.skipIf(isDebug)( + 'filesystem-only auto-pause reboots on connect', + async () => { + // keepMemory:false makes the timeout auto-pause filesystem-only, so resuming + // cold-boots the sandbox from disk. + const sandbox = await Sandbox.create(template, { + timeoutMs: 3_000, + lifecycle: { onTimeout: { action: 'pause', keepMemory: false } }, + }) + + try { + const marker = 'auto-pause-fs-only' + await sandbox.files.write('/home/user/auto-pause-marker.txt', marker) + const bootBefore = ( + await sandbox.files.read('/proc/sys/kernel/random/boot_id') + ).trim() + + await wait(5_000) + + assert.equal((await sandbox.getInfo()).state, 'paused') + + // A filesystem-only snapshot cannot auto-resume on traffic; connect + // resumes it by cold-booting. + await sandbox.connect() + + const persisted = ( + await sandbox.files.read('/home/user/auto-pause-marker.txt') + ).trim() + assert.equal(persisted, marker) + + const bootAfter = ( + await sandbox.files.read('/proc/sys/kernel/random/boot_id') + ).trim() + assert.notEqual(bootAfter, bootBefore) + } finally { + await sandbox.kill().catch(() => {}) + } + }, + 60_000 +) + +test.skipIf(isDebug)( + 'auto-resume wakes paused sandbox on http request', + async () => { + const sandbox = await Sandbox.create(template, { + timeoutMs: 3_000, + lifecycle: { + onTimeout: 'pause', + autoResume: true, + }, + }) + + try { + await sandbox.commands.run('python3 -m http.server 8000', { + background: true, + }) + + await wait(5_000) + + const url = `https://${sandbox.getHost(8000)}` + const res = await fetch(url, { signal: AbortSignal.timeout(15_000) }) + + assert.equal(res.status, 200) + assert.equal((await sandbox.getInfo()).state, 'running') + assert.isTrue(await sandbox.isRunning()) + } finally { + await sandbox.kill().catch(() => {}) + } + }, + 60_000 +) diff --git a/packages/js-sdk/tests/sandbox/metrics.test.ts b/packages/js-sdk/tests/sandbox/metrics.test.ts new file mode 100644 index 0000000..d7dbca5 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/metrics.test.ts @@ -0,0 +1,69 @@ +import { expect } from 'vitest' + +import { SandboxMetrics } from '../../src' +import { sandboxTest, isDebug, wait } from '../setup.js' + +sandboxTest.skipIf(isDebug)( + 'sbx metrics', + { timeout: 60_000 }, + async ({ sandbox }) => { + // Wait for the sandbox to have some metrics + let metrics: SandboxMetrics[] = [] + for (let i = 0; i < 60; i++) { + metrics = await sandbox.getMetrics() + if (metrics.length > 0) { + break + } + await wait(500) + } + + expect(metrics.length).toBeGreaterThan(0) + const metric = metrics[0] + expect(metric.diskTotal).toBeDefined() + expect(metric.diskUsed).toBeDefined() + expect(metric.memTotal).toBeDefined() + expect(metric.memUsed).toBeDefined() + expect(metric.cpuUsedPct).toBeDefined() + expect(metric.cpuCount).toBeDefined() + } +) + +sandboxTest.skipIf(isDebug)( + 'sbx metrics time range', + { timeout: 60_000 }, + async ({ sandbox }) => { + const start = new Date() + + // Wait for the sandbox to have some metrics within the test's time window + let metrics: SandboxMetrics[] = [] + let end = new Date() + for (let i = 0; i < 60; i++) { + end = new Date() + metrics = await sandbox.getMetrics({ start, end }) + if (metrics.length > 0) { + break + } + await wait(500) + } + + expect(metrics.length).toBeGreaterThan(0) + + // All returned metrics must fall within the requested time range + // (10s slack — metric timestamps are aligned to collection buckets, + // currently 5s, and the query params are second-precision) + const slackMs = 10_000 + for (const m of metrics) { + expect(m.timestamp.getTime()).toBeGreaterThanOrEqual( + start.getTime() - slackMs + ) + expect(m.timestamp.getTime()).toBeLessThanOrEqual(end.getTime() + slackMs) + } + + // A time range from before the sandbox existed must return no metrics + const noMetrics = await sandbox.getMetrics({ + start: new Date(start.getTime() - 60 * 60 * 1000), + end: new Date(start.getTime() - 30 * 60 * 1000), + }) + expect(noMetrics).toHaveLength(0) + } +) diff --git a/packages/js-sdk/tests/sandbox/network.test.ts b/packages/js-sdk/tests/sandbox/network.test.ts new file mode 100644 index 0000000..2da46ff --- /dev/null +++ b/packages/js-sdk/tests/sandbox/network.test.ts @@ -0,0 +1,359 @@ +import { assert, expect, describe } from 'vitest' + +import { CommandExitError } from '../../src' +import { sandboxTest, isDebug } from '../setup.js' + +describe('allow only 1.1.1.1', () => { + sandboxTest.scoped({ + sandboxOpts: { + network: { + denyOut: ({ allTraffic }) => [allTraffic], + allowOut: ['1.1.1.1'], + }, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'allow specific IP with deny all traffic', + async ({ sandbox }) => { + // Test that allowed IP works + const result = await sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert.equal(result.exitCode, 0) + assert.equal(result.stdout.trim(), '301') + + // Test that other IPs are denied + await expect( + sandbox.commands.run( + 'curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8' + ) + ).rejects.toBeInstanceOf(CommandExitError) + } + ) +}) + +describe('deny specific IP address', () => { + sandboxTest.scoped({ + sandboxOpts: { + network: { + denyOut: ['8.8.8.8'], + }, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'deny specific IP address', + async ({ sandbox }) => { + // Test that denied IP fails + await expect( + sandbox.commands.run( + 'curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8' + ) + ).rejects.toBeInstanceOf(CommandExitError) + + // Test that other IPs work + const result = await sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert.equal(result.exitCode, 0) + assert.equal(result.stdout.trim(), '301') + } + ) +}) + +describe('deny all traffic using allTraffic selector', () => { + sandboxTest.scoped({ + sandboxOpts: { + network: { + denyOut: ({ allTraffic }) => [allTraffic], + }, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'deny all traffic using allTraffic selector', + async ({ sandbox }) => { + // Test that all traffic is denied + await expect( + sandbox.commands.run( + 'curl --connect-timeout 3 --max-time 5 -Is https://1.1.1.1' + ) + ).rejects.toBeInstanceOf(CommandExitError) + + await expect( + sandbox.commands.run( + 'curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8' + ) + ).rejects.toBeInstanceOf(CommandExitError) + } + ) +}) + +describe('allow takes precedence over deny', () => { + sandboxTest.scoped({ + sandboxOpts: { + network: { + denyOut: ({ allTraffic }) => [allTraffic], + allowOut: ['1.1.1.1', '8.8.8.8'], + }, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'allow takes precedence over deny', + async ({ sandbox }) => { + // Test that 1.1.1.1 works (explicitly allowed) + const result1 = await sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert.equal(result1.exitCode, 0) + assert.equal(result1.stdout.trim(), '301') + + // Test that 8.8.8.8 also works (explicitly allowed, takes precedence over denyOut) + const result2 = await sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8" + ) + assert.equal(result2.exitCode, 0) + assert.equal(result2.stdout.trim(), '302') + } + ) +}) + +describe('allowPublicTraffic=false', () => { + sandboxTest.scoped({ + sandboxOpts: { + network: { + allowPublicTraffic: false, + }, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'sandbox requires traffic access token', + async ({ sandbox }) => { + // Verify the sandbox was created successfully and has a traffic access token + assert(sandbox.trafficAccessToken) + + // Start a simple HTTP server in the sandbox + const port = 8080 + sandbox.commands.run(`python3 -m http.server ${port}`, { + background: true, + }) + + // Wait for server to start + await new Promise((resolve) => setTimeout(resolve, 3000)) + + // Get the public URL for the sandbox + const sandboxUrl = `https://${sandbox.getHost(port)}` + + // Test 1: Request without traffic access token should fail with 403 + const response1 = await fetch(sandboxUrl) + assert.equal(response1.status, 403) + + // Test 2: Request with valid traffic access token should succeed + const response2 = await fetch(sandboxUrl, { + headers: { + 'e2b-traffic-access-token': sandbox.trafficAccessToken, + }, + }) + assert.equal(response2.status, 200) + } + ) +}) + +describe('allowPublicTraffic=true', () => { + sandboxTest.scoped({ + sandboxOpts: { + network: { + allowPublicTraffic: true, + }, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'sandbox works without token', + async ({ sandbox }) => { + // Start a simple HTTP server in the sandbox + const port = 8080 + sandbox.commands.run(`python3 -m http.server ${port}`, { + background: true, + }) + + // Wait for server to start + await new Promise((resolve) => setTimeout(resolve, 3000)) + + // Get the public URL for the sandbox + const sandboxUrl = `https://${sandbox.getHost(port)}` + + // Request without traffic access token should succeed (public access enabled) + const response = await fetch(sandboxUrl) + assert.equal(response.status, 200) + } + ) +}) + +describe('firewall transform injects headers', () => { + const injectedHeader = 'X-E2B-Test-Token' + const injectedValue = 'e2b-transform-value-123' + + sandboxTest.scoped({ + sandboxOpts: { + network: { + rules: { + 'httpbin.e2b.team': [ + { + transform: { + headers: { + [injectedHeader]: injectedValue, + }, + }, + }, + ], + }, + }, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'injected header is reflected by httpbin.e2b.team/headers', + async ({ sandbox }) => { + const result = await sandbox.commands.run( + 'curl -sS --max-time 10 https://httpbin.e2b.team/headers' + ) + assert.equal(result.exitCode, 0) + + const parsed = JSON.parse(result.stdout) as { + headers: Record + } + const reflected = parsed.headers[injectedHeader] + assert.equal( + reflected, + injectedValue, + `expected httpbin to reflect ${injectedHeader}=${injectedValue}, got headers: ${JSON.stringify(parsed.headers)}` + ) + } + ) +}) + +describe('updateNetwork applies new egress rules', () => { + sandboxTest.skipIf(isDebug)( + 'denies a previously reachable IP after update', + async ({ sandbox }) => { + // Baseline: 8.8.8.8 is reachable. + const before = await sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8" + ) + assert.equal(before.exitCode, 0) + + await sandbox.updateNetwork({ denyOut: ['8.8.8.8'] }) + + // 8.8.8.8 should now be denied. + await expect( + sandbox.commands.run( + 'curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8' + ) + ).rejects.toBeInstanceOf(CommandExitError) + + // Other destinations stay reachable. + const after = await sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert.equal(after.exitCode, 0) + } + ) +}) + +describe('updateNetwork clears existing rules when fields are omitted', () => { + sandboxTest.scoped({ + sandboxOpts: { + network: { + denyOut: ({ allTraffic }) => [allTraffic], + allowOut: ['1.1.1.1'], + }, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'omitting fields replaces all egress rules', + async ({ sandbox }) => { + // Baseline from create-time config: 8.8.8.8 denied. + await expect( + sandbox.commands.run( + 'curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8' + ) + ).rejects.toBeInstanceOf(CommandExitError) + + // Empty update clears allow_out / deny_out entirely. + await sandbox.updateNetwork({}) + + const r1 = await sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert.equal(r1.exitCode, 0) + + const r2 = await sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8" + ) + assert.equal(r2.exitCode, 0) + } + ) +}) + +describe('maskRequestHost option', () => { + sandboxTest.scoped({ + sandboxOpts: { + network: { + maskRequestHost: 'custom-host.example.com:${PORT}', + }, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'verify maskRequestHost modifies Host header correctly', + async ({ sandbox }) => { + const port = 8080 + const outputFile = '/tmp/headers.txt' + + // Start a Python HTTP server that captures request headers and writes them to a file + sandbox.commands.run( + `python3 -c " +import http.server +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + with open('${outputFile}', 'w') as f: + for k, v in self.headers.items(): + f.write(k + ': ' + v + chr(10)) + self.send_response(200) + self.end_headers() + def log_message(self, *a): pass +http.server.HTTPServer(('', ${port}), H).handle_request() +"`, + { background: true } + ) + + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // Get the public URL for the sandbox + const sandboxUrl = `https://${sandbox.getHost(port)}` + + // Make a request from OUTSIDE the sandbox through the proxy + // The Host header should be modified according to maskRequestHost + try { + await fetch(sandboxUrl, { signal: AbortSignal.timeout(5000) }) + } catch (error) { + // Request may timeout, but headers are captured by the server + } + + await new Promise((resolve) => setTimeout(resolve, 1000)) + + // Read the captured headers from inside the sandbox + const result = await sandbox.commands.run(`cat ${outputFile}`) + + // Verify the Host header was modified according to maskRequestHost + assert.include(result.stdout, 'Host:') + assert.include(result.stdout, 'custom-host.example.com') + assert.include(result.stdout, `${port}`) + } + ) +}) diff --git a/packages/js-sdk/tests/sandbox/pty/kill.test.ts b/packages/js-sdk/tests/sandbox/pty/kill.test.ts new file mode 100644 index 0000000..f602515 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/pty/kill.test.ts @@ -0,0 +1,25 @@ +import { sandboxTest } from '../../setup' +import { assert, expect } from 'vitest' +import { ProcessExitError } from '../../../src/index.js' + +sandboxTest('kill PTY', async ({ sandbox }) => { + const terminal = await sandbox.pty.create({ + cols: 80, + rows: 24, + onData: () => {}, + }) + + const result = await sandbox.pty.kill(terminal.pid) + assert.isTrue(result) + + // The PTY process should no longer be running. + await expect( + sandbox.commands.run(`kill -0 ${terminal.pid}`) + ).rejects.toThrowError(ProcessExitError) +}) + +sandboxTest('kill non-existing PTY', async ({ sandbox }) => { + const nonExistingPid = 999999 + + await expect(sandbox.pty.kill(nonExistingPid)).resolves.toBe(false) +}) diff --git a/packages/js-sdk/tests/sandbox/pty/ptyConnect.test.ts b/packages/js-sdk/tests/sandbox/pty/ptyConnect.test.ts new file mode 100644 index 0000000..903da6d --- /dev/null +++ b/packages/js-sdk/tests/sandbox/pty/ptyConnect.test.ts @@ -0,0 +1,48 @@ +import { sandboxTest } from '../../setup' +import { assert } from 'vitest' + +sandboxTest('pty connect/reconnect', async ({ sandbox }) => { + let output1 = '' + let output2 = '' + const decoder = new TextDecoder() + + // First, create a terminal and disconnect the onData handler + const terminal = await sandbox.pty.create({ + cols: 80, + rows: 24, + onData: (data: Uint8Array) => { + output1 += decoder.decode(data) + }, + envs: { FOO: 'bar' }, + }) + + await sandbox.pty.sendInput( + terminal.pid, + new Uint8Array(Buffer.from('echo $FOO\n')) + ) + + // Give time for the command output in the first connection + await new Promise((r) => setTimeout(r, 300)) + + await terminal.disconnect() + + // Now connect again, with a new onData handler + const reconnectHandle = await sandbox.pty.connect(terminal.pid, { + onData: (data: Uint8Array) => { + output2 += decoder.decode(data) + }, + }) + + await sandbox.pty.sendInput( + terminal.pid, + new Uint8Array(Buffer.from('echo $FOO\nexit\n')) + ) + + await reconnectHandle.wait() + + assert.equal(terminal.pid, reconnectHandle.pid) + assert.equal(reconnectHandle.exitCode, 0) + + assert.include(output1, 'bar') + assert.include(output2, 'bar') +}) diff --git a/packages/js-sdk/tests/sandbox/pty/ptyCreate.test.ts b/packages/js-sdk/tests/sandbox/pty/ptyCreate.test.ts new file mode 100644 index 0000000..5a0b98c --- /dev/null +++ b/packages/js-sdk/tests/sandbox/pty/ptyCreate.test.ts @@ -0,0 +1,27 @@ +import { sandboxTest } from '../../setup' +import { assert } from 'vitest' + +sandboxTest('create PTY', async ({ sandbox }) => { + let output = '' + const decoder = new TextDecoder() + const appendData = (data: Uint8Array) => { + output += decoder.decode(data) + } + + const terminal = await sandbox.pty.create({ + cols: 80, + rows: 24, + onData: appendData, + envs: { ABC: '123' }, + }) + + await sandbox.pty.sendInput( + terminal.pid, + new Uint8Array(Buffer.from('echo $ABC\nexit\n')) + ) + + await terminal.wait() + assert.equal(terminal.exitCode, 0) + + assert.include(output, '123') +}) diff --git a/packages/js-sdk/tests/sandbox/pty/resize.test.ts b/packages/js-sdk/tests/sandbox/pty/resize.test.ts new file mode 100644 index 0000000..21bb878 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/pty/resize.test.ts @@ -0,0 +1,42 @@ +import { sandboxTest } from '../../setup' +import { assert } from 'vitest' + +sandboxTest('resize', async ({ sandbox }) => { + let output = '' + const decoder = new TextDecoder() + const appendData = (data: Uint8Array) => { + output += decoder.decode(data) + } + + const terminal = await sandbox.pty.create({ + cols: 80, + rows: 24, + onData: appendData, + }) + + await sandbox.pty.sendInput( + terminal.pid, + new Uint8Array(Buffer.from('tput cols\nexit\n')) + ) + + await terminal.wait() + assert.equal(terminal.exitCode, 0) + assert.include(output, '80') + + output = '' + + const resizedTerminal = await sandbox.pty.create({ + cols: 80, + rows: 24, + onData: appendData, + }) + await sandbox.pty.resize(resizedTerminal.pid, { cols: 100, rows: 24 }) + await sandbox.pty.sendInput( + resizedTerminal.pid, + new Uint8Array(Buffer.from('tput cols\nexit\n')) + ) + + await resizedTerminal.wait() + assert.equal(resizedTerminal.exitCode, 0) + assert.include(output, '100') +}) diff --git a/packages/js-sdk/tests/sandbox/pty/sendInput.test.ts b/packages/js-sdk/tests/sandbox/pty/sendInput.test.ts new file mode 100644 index 0000000..b32d7f2 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/pty/sendInput.test.ts @@ -0,0 +1,18 @@ +import { expect } from 'vitest' +import { sandboxTest } from '../../setup' + +sandboxTest('send input', async ({ sandbox }) => { + const terminal = await sandbox.pty.create({ + cols: 80, + rows: 24, + onData: () => null, + }) + + await sandbox.pty.sendInput( + terminal.pid, + new Uint8Array(Buffer.from('exit\n')) + ) + + await terminal.wait() + expect(terminal.exitCode).toBe(0) +}) diff --git a/packages/js-sdk/tests/sandbox/rpcHeaders.test.ts b/packages/js-sdk/tests/sandbox/rpcHeaders.test.ts new file mode 100644 index 0000000..a559713 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/rpcHeaders.test.ts @@ -0,0 +1,49 @@ +import { assert, test, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + rpcFetch: vi.fn(async () => new Response(null, { status: 204 })), + transportFetch: undefined as typeof fetch | undefined, +})) + +vi.mock('@connectrpc/connect-web', () => ({ + createConnectTransport: vi.fn((opts: { fetch: typeof fetch }) => { + mocks.transportFetch = opts.fetch + return {} + }), +})) + +vi.mock('../../src/envd/http2', () => ({ + createEnvdFetch: vi.fn(() => vi.fn()), + createEnvdRpcFetch: vi.fn(() => mocks.rpcFetch), +})) + +test('does not pass custom connection headers to envd RPC requests', async () => { + const { ConnectionConfig, Sandbox } = await import('../../src') + const config = new ConnectionConfig() + const sandbox = new Sandbox({ + ...config, + sandboxId: 'sbx-test', + sandboxDomain: 'sandbox.e2b.dev', + envdVersion: '0.2.4', + envdAccessToken: 'tok', + headers: { + Authorization: 'Bearer user-token', + 'X-Custom': 'secret', + }, + }) + + assert.equal(sandbox.sandboxId, 'sbx-test') + assert.ok(mocks.transportFetch) + await mocks.transportFetch('https://sandbox.e2b.dev/rpc', { + headers: { 'Connect-Protocol-Version': '1' }, + }) + + const headers = new Headers(mocks.rpcFetch.mock.calls[0][1]?.headers) + + assert.equal(headers.get('Authorization'), null) + assert.equal(headers.get('X-Custom'), null) + assert.equal(headers.get('User-Agent')?.startsWith('e2b-js-sdk/'), true) + assert.equal(headers.get('E2b-Sandbox-Id'), 'sbx-test') + assert.equal(headers.get('X-Access-Token'), 'tok') + assert.equal(headers.get('Connect-Protocol-Version'), '1') +}) diff --git a/packages/js-sdk/tests/sandbox/secure.test.ts b/packages/js-sdk/tests/sandbox/secure.test.ts new file mode 100644 index 0000000..64f9509 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/secure.test.ts @@ -0,0 +1,115 @@ +import { assert, test, describe } from 'vitest' +import { getSignature, Sandbox } from '../../src' +import { sandboxTest, isDebug } from '../setup' +import { randomUUID, createHash } from 'node:crypto' + +describe('secure sandbox', () => { + sandboxTest.scoped({ + sandboxOpts: { + secure: true, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'test access file with signing', + async ({ sandbox }) => { + await sandbox.files.write('hello.txt', 'hello world') + + const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt') + + const res = await fetch(fileUrlWithSigning) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 200) + assert.equal(resBody, 'hello world') + } + ) + + sandboxTest.skipIf(isDebug)( + 'try to re-connect to sandbox', + async ({ sandbox }) => { + const sbxReconnect = await Sandbox.connect(sandbox.sandboxId) + + await sbxReconnect.files.write('hello.txt', 'hello world') + } + ) +}) + +test.skipIf(isDebug)('signing generation', async () => { + const operation = 'read' + const path = '/home/user/hello.txt' + const user = 'root' + const envdAccessToken = randomUUID() + + const signatureRaw = `${path}:${operation}:${user}:${envdAccessToken}` + + const buff = Buffer.from(signatureRaw, 'utf8') + const hash = createHash('sha256').update(buff).digest() + const signature = 'v1_' + hash.toString('base64').replace(/=+$/, '') + + const readSignatureExpected = { + signature: signature, + expiration: null, + } + + const readSignatureReceived = await getSignature({ + path, + operation, + user, + envdAccessToken, + }) + + assert.deepEqual(readSignatureExpected, readSignatureReceived) +}) + +test.skipIf(isDebug)('signing generation with expiration', async () => { + const operation = 'read' + const path = '/home/user/hello.txt' + const user = 'root' + const envdAccessToken = randomUUID() + const expirationInSeconds = 120 + + const signatureExpiration = expirationInSeconds + ? Math.floor(Date.now() / 1000) + expirationInSeconds + : null + const signatureRaw = `${path}:${operation}:${user}:${envdAccessToken}:${signatureExpiration?.toString()}` + + const buff = Buffer.from(signatureRaw, 'utf8') + const hash = createHash('sha256').update(buff).digest() + const signature = 'v1_' + hash.toString('base64').replace(/=+$/, '') + + const readSignatureExpected = { + signature: signature, + expiration: signatureExpiration, + } + + const readSignatureReceived = await getSignature({ + path, + operation, + user, + envdAccessToken, + expirationInSeconds, + }) + + assert.deepEqual(readSignatureExpected, readSignatureReceived) +}) + +test.skipIf(isDebug)('static signing key comparison', async () => { + const operation = 'read' + const path = 'hello.txt' + const user = 'user' + const envdAccessToken = '0tQG31xiMp0IOQfaz9dcwi72L1CPo8e0' + + const signatureReceived = await getSignature({ + path, + operation, + user, + envdAccessToken, + }) + + assert.equal( + 'v1_gUtH/s9YCJWgCizjfUxuWfhFE4QSydOWEIIvfLwDr6E', + signatureReceived.signature + ) +}) diff --git a/packages/js-sdk/tests/sandbox/snapshot-api.test.ts b/packages/js-sdk/tests/sandbox/snapshot-api.test.ts new file mode 100644 index 0000000..e22387a --- /dev/null +++ b/packages/js-sdk/tests/sandbox/snapshot-api.test.ts @@ -0,0 +1,212 @@ +import { assert } from 'vitest' + +import { sandboxTest, isDebug } from '../setup.js' +import { Sandbox } from '../../src' + +sandboxTest.skipIf(isDebug)( + 'create a snapshot from sandbox', + async ({ sandbox }) => { + // Write a file to the sandbox + await sandbox.files.write('/home/user/test.txt', 'snapshot test content') + + // Create a snapshot + const snapshot = await sandbox.createSnapshot() + + assert.isString(snapshot.snapshotId) + assert.isTrue(snapshot.snapshotId.length > 0) + + // Cleanup + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } +) + +sandboxTest.skipIf(isDebug)( + 'create sandbox from snapshot', + async ({ sandbox, sandboxTestId }) => { + const testContent = 'content from original sandbox' + + // Write a file to the sandbox + await sandbox.files.write('/home/user/test.txt', testContent) + + // Create a snapshot + const snapshot = await sandbox.createSnapshot() + + try { + // Create a new sandbox from the snapshot + const newSandbox = await Sandbox.create(snapshot.snapshotId, { + metadata: { sandboxTestId: `${sandboxTestId}-from-snapshot` }, + }) + + try { + // Verify the file exists in the new sandbox + const content = await newSandbox.files.read('/home/user/test.txt') + assert.equal(content, testContent) + } finally { + await newSandbox.kill() + } + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } + } +) + +sandboxTest.skipIf(isDebug)( + 'create multiple sandboxes from same snapshot', + async ({ sandbox, sandboxTestId }) => { + const testContent = 'shared snapshot content' + + await sandbox.files.write('/home/user/shared.txt', testContent) + + const snapshot = await sandbox.createSnapshot() + + try { + // Create two sandboxes from the same snapshot + const sandbox1 = await Sandbox.create(snapshot.snapshotId, { + metadata: { sandboxTestId: `${sandboxTestId}-branch-1` }, + }) + const sandbox2 = await Sandbox.create(snapshot.snapshotId, { + metadata: { sandboxTestId: `${sandboxTestId}-branch-2` }, + }) + + try { + // Both should have the same initial content + const content1 = await sandbox1.files.read('/home/user/shared.txt') + const content2 = await sandbox2.files.read('/home/user/shared.txt') + + assert.equal(content1, testContent) + assert.equal(content2, testContent) + + // Modify one sandbox - should not affect the other + await sandbox1.files.write( + '/home/user/shared.txt', + 'modified in sandbox1' + ) + + const modifiedContent = await sandbox1.files.read( + '/home/user/shared.txt' + ) + const unchangedContent = await sandbox2.files.read( + '/home/user/shared.txt' + ) + + assert.equal(modifiedContent, 'modified in sandbox1') + assert.equal(unchangedContent, testContent) + } finally { + await sandbox1.kill() + await sandbox2.kill() + } + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } + } +) + +sandboxTest.skipIf(isDebug)('list snapshots', async ({ sandbox }) => { + // Create a snapshot + const snapshot = await sandbox.createSnapshot() + + try { + // List all snapshots + const paginator = Sandbox.listSnapshots() + assert.isTrue(paginator.hasNext) + + const snapshots = await paginator.nextItems() + assert.isArray(snapshots) + + // Find our snapshot in the list + const found = snapshots.find((s) => s.snapshotId === snapshot.snapshotId) + assert.isDefined(found) + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } +}) + +sandboxTest.skipIf(isDebug)( + 'list snapshots for specific sandbox', + async ({ sandbox }) => { + // Create a snapshot + const snapshot = await sandbox.createSnapshot() + + try { + // List snapshots for this sandbox using instance method + const paginator = sandbox.listSnapshots() + const snapshots = await paginator.nextItems() + + // Should find our snapshot + const found = snapshots.find((s) => s.snapshotId === snapshot.snapshotId) + assert.isDefined(found) + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } + } +) + +sandboxTest.skipIf(isDebug)( + 'create a named snapshot', + async ({ sandbox, sandboxTestId }) => { + const snapshotName = `snap-${sandboxTestId}` + + const snapshot = await sandbox.createSnapshot({ name: snapshotName }) + + try { + assert.isString(snapshot.snapshotId) + assert.isArray(snapshot.names) + assert.isTrue(snapshot.names.length > 0) + assert.isTrue(snapshot.names.some((n) => n.includes(snapshotName))) + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } + } +) + +sandboxTest.skipIf(isDebug)('delete snapshot', async ({ sandbox }) => { + const snapshot = await sandbox.createSnapshot() + + // Delete should succeed + const deleted = await Sandbox.deleteSnapshot(snapshot.snapshotId) + assert.isTrue(deleted) + + // Second delete should return false (not found) + const deletedAgain = await Sandbox.deleteSnapshot(snapshot.snapshotId) + assert.isFalse(deletedAgain) +}) + +sandboxTest.skipIf(isDebug)( + 'snapshot preserves file system state', + async ({ sandbox, sandboxTestId }) => { + const appDir = '/home/user/app' + const configPath = `${appDir}/config.json` + const configContent = '{"env": "test"}' + const dataPath = `${appDir}/data.txt` + const dataContent = 'important data' + + await sandbox.files.makeDir(appDir) + await sandbox.files.write(configPath, configContent) + await sandbox.files.write(dataPath, dataContent) + + const snapshot = await sandbox.createSnapshot() + + try { + const newSandbox = await Sandbox.create(snapshot.snapshotId, { + metadata: { sandboxTestId: `${sandboxTestId}-fs-test` }, + }) + + try { + // Verify directory exists + const dirExists = await newSandbox.files.exists(appDir) + assert.isTrue(dirExists) + + // Verify files exist with correct content + const config = await newSandbox.files.read(configPath) + const data = await newSandbox.files.read(dataPath) + + assert.equal(config, configContent) + assert.equal(data, dataContent) + } finally { + await newSandbox.kill() + } + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } + } +) diff --git a/packages/js-sdk/tests/sandbox/snapshot.test.ts b/packages/js-sdk/tests/sandbox/snapshot.test.ts new file mode 100644 index 0000000..af88640 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/snapshot.test.ts @@ -0,0 +1,203 @@ +import { assert, describe } from 'vitest' + +import { sandboxTest, isDebug } from '../setup.js' + +sandboxTest.skipIf(isDebug)( + 'pause and resume a sandbox', + async ({ sandbox }) => { + assert.isTrue(await sandbox.isRunning()) + + await sandbox.pause() + + assert.isFalse(await sandbox.isRunning()) + + const resumedSandbox = await sandbox.connect() + assert.equal(resumedSandbox.sandboxId, sandbox.sandboxId) + + assert.isTrue(await sandbox.isRunning()) + } +) + +describe('pause and resume with env vars', () => { + sandboxTest.scoped({ + sandboxOpts: { + envs: { TEST_VAR: 'sfisback' }, + }, + }) + + sandboxTest.skipIf(isDebug)( + 'pause and resume a sandbox with env vars', + async ({ sandbox }) => { + // Environment variables of a process exist at runtime, and are not stored in some file or so. + // They are stored in the process's own memory + const cmd = await sandbox.commands.run('echo "$TEST_VAR"') + + assert.equal(cmd.exitCode, 0) + assert.equal(cmd.stdout.trim(), 'sfisback') + + await sandbox.pause() + + assert.isFalse(await sandbox.isRunning()) + + const resumedSandbox = await sandbox.connect() + assert.isTrue(await sandbox.isRunning()) + assert.isTrue(await resumedSandbox.isRunning()) + assert.equal(resumedSandbox.sandboxId, sandbox.sandboxId) + + const cmd2 = await sandbox.commands.run('echo "$TEST_VAR"') + + assert.equal(cmd2.exitCode, 0) + assert.equal(cmd2.stdout.trim(), 'sfisback') + } + ) +}) + +sandboxTest.skipIf(isDebug)( + 'pause and resume a sandbox with file', + async ({ sandbox }) => { + const filename = 'test_snapshot.txt' + const content = 'This is a snapshot test file.' + + const info = await sandbox.files.write(filename, content) + assert.equal(info.name, filename) + assert.equal(info.type, 'file') + assert.equal(info.path, `/home/user/${filename}`) + + const exists = await sandbox.files.exists(filename) + assert.isTrue(exists) + const readContent = await sandbox.files.read(filename) + assert.equal(readContent, content) + + await sandbox.pause() + assert.isFalse(await sandbox.isRunning()) + + await sandbox.connect() + assert.isTrue(await sandbox.isRunning()) + + const exists2 = await sandbox.files.exists(filename) + assert.isTrue(exists2) + const readContent2 = await sandbox.files.read(filename) + assert.equal(readContent2, content) + } +) + +sandboxTest.skipIf(isDebug)( + 'pause and resume a sandbox with ongoing long running process', + async ({ sandbox }) => { + const cmd = await sandbox.commands.run('sleep 3600', { background: true }) + const expectedPid = cmd.pid + + await sandbox.pause() + assert.isFalse(await sandbox.isRunning()) + + await sandbox.connect() + assert.isTrue(await sandbox.isRunning()) + + // First check that the command is in list + const list = await sandbox.commands.list() + assert.isTrue(list.some((c) => c.pid === expectedPid)) + + // Make sure we can connect to it + const processInfo = await sandbox.commands.connect(expectedPid) + + assert.isObject(processInfo) + assert.equal(processInfo.pid, expectedPid) + } +) + +sandboxTest.skipIf(isDebug)( + 'pause and resume a sandbox with completed long running process', + async ({ sandbox }) => { + const filename = 'test_long_running.txt' + + await sandbox.commands.run( + `sleep 2 && echo "done" > /home/user/${filename}`, + { + background: true, + } + ) + + // the file should not exist before 2 seconds have elapsed + const exists = await sandbox.files.exists(filename) + assert.isFalse(exists) + + await sandbox.pause() + assert.isFalse(await sandbox.isRunning()) + + await sandbox.connect() + assert.isTrue(await sandbox.isRunning()) + + // the file should be created after more than 2 seconds have elapsed + await new Promise((resolve) => setTimeout(resolve, 2000)) + + const exists2 = await sandbox.files.exists(filename) + assert.isTrue(exists2) + const readContent2 = await sandbox.files.read(filename) + assert.equal(readContent2.trim(), 'done') + } +) + +sandboxTest.skipIf(isDebug)( + 'pause and resume a sandbox with http server', + async ({ sandbox }) => { + await sandbox.commands.run('python3 -m http.server 8000', { + background: true, + }) + + let url = await sandbox.getHost(8000) + + await new Promise((resolve) => setTimeout(resolve, 5000)) + + const response1 = await fetch(`https://${url}`) + assert.equal(response1.status, 200) + + await sandbox.pause() + assert.isFalse(await sandbox.isRunning()) + + await sandbox.connect() + assert.isTrue(await sandbox.isRunning()) + + url = await sandbox.getHost(8000) + const response2 = await fetch(`https://${url}`) + assert.equal(response2.status, 200) + } +) + +sandboxTest.skipIf(isDebug)( + 'filesystem-only pause reboots on resume but keeps the filesystem', + async ({ sandbox }) => { + // Absolute path: a cold boot may not restore the template's default + // user/cwd, so a relative path could resolve differently after resume. + const filename = '/home/user/fs_only_snapshot.txt' + const content = 'This is a filesystem-only snapshot test file.' + + await sandbox.files.write(filename, content) + + // Kernel boot id before the pause; it changes only across a real (cold) boot. + const bootBefore = ( + await sandbox.files.read('/proc/sys/kernel/random/boot_id') + ).trim() + + // Filesystem-only snapshot: no memory is captured, so resuming cold-boots. + await sandbox.pause({ keepMemory: false }) + assert.isFalse(await sandbox.isRunning()) + + // Resume the paused sandbox; a filesystem-only pause keeps no memory, so + // connect() cold-boots (reboots) it. connect() returns the same handle, and + // its credentials stay valid across the resume (the backend re-binds the + // same envd access token on the cold boot). + const resumedSandbox = await sandbox.connect() + assert.equal(resumedSandbox.sandboxId, sandbox.sandboxId) + assert.isTrue(await resumedSandbox.isRunning()) + + // The filesystem survives the cold boot. + assert.isTrue(await resumedSandbox.files.exists(filename)) + assert.equal(await resumedSandbox.files.read(filename), content) + + // A fresh boot id proves the guest rebooted rather than restoring memory. + const bootAfter = ( + await resumedSandbox.files.read('/proc/sys/kernel/random/boot_id') + ).trim() + assert.notEqual(bootAfter, bootBefore) + } +) diff --git a/packages/js-sdk/tests/sandbox/timeout.test.ts b/packages/js-sdk/tests/sandbox/timeout.test.ts new file mode 100644 index 0000000..249666a --- /dev/null +++ b/packages/js-sdk/tests/sandbox/timeout.test.ts @@ -0,0 +1,31 @@ +import { expect } from 'vitest' + +import { sandboxTest, isDebug, wait } from '../setup.js' + +sandboxTest.skipIf(isDebug)('shorten timeout', async ({ sandbox }) => { + await sandbox.setTimeout(5000) + + await wait(6000) + + expect(await sandbox.isRunning()).toBeFalsy() +}) + +sandboxTest.skipIf(isDebug)( + 'shorten then lengthen timeout', + async ({ sandbox }) => { + await sandbox.setTimeout(5000) + + await wait(1000) + + await sandbox.setTimeout(10000) + + await wait(6000) + + expect(await sandbox.isRunning()).toBeTruthy() + } +) + +sandboxTest.skipIf(isDebug)('get sandbox timeout', async ({ sandbox }) => { + const { endAt } = await sandbox.getInfo() + expect(endAt).toBeInstanceOf(Date) +}) diff --git a/packages/js-sdk/tests/sandbox/urls.test.ts b/packages/js-sdk/tests/sandbox/urls.test.ts new file mode 100644 index 0000000..f20854e --- /dev/null +++ b/packages/js-sdk/tests/sandbox/urls.test.ts @@ -0,0 +1,90 @@ +import { assert, describe, test } from 'vitest' + +import { getSignature, InvalidArgumentError, Sandbox } from '../../src' +import { TEST_API_KEY } from '../setup' + +function createSandbox(envdAccessToken?: string) { + return new Sandbox({ + sandboxId: 'sandbox-id', + sandboxDomain: 'e2b.app', + envdVersion: '0.4.0', + envdAccessToken, + apiKey: TEST_API_KEY, + domain: 'e2b.app', + debug: false, + }) +} + +describe('sandbox file URLs', () => { + test('file URLs use direct sandbox host when envd API uses stable host', async () => { + const sandbox = createSandbox() + + assert.equal(sandbox['envdApiUrl'], 'https://sandbox.e2b.app') + assert.equal(sandbox['envdDirectUrl'], 'https://49983-sandbox-id.e2b.app') + assert.equal( + await sandbox.downloadUrl('/tmp/a.txt'), + 'https://49983-sandbox-id.e2b.app/files?path=%2Ftmp%2Fa.txt' + ) + assert.equal( + await sandbox.uploadUrl('/tmp/a.txt'), + 'https://49983-sandbox-id.e2b.app/files?path=%2Ftmp%2Fa.txt' + ) + }) + + test('throws when signature expiration is used on unsecured sandbox', async () => { + const sandbox = createSandbox() + + await Promise.all( + [ + sandbox.downloadUrl('/tmp/a.txt', { useSignatureExpiration: 120 }), + sandbox.uploadUrl('/tmp/a.txt', { useSignatureExpiration: 120 }), + ].map((promise) => + promise.then( + () => assert.fail('expected InvalidArgumentError'), + (err) => { + assert.instanceOf(err, InvalidArgumentError) + assert.equal( + err.message, + 'Signature expiration can be used only when sandbox is created as secured.' + ) + } + ) + ) + ) + }) + + test('zero signature expiration expires immediately', async () => { + const before = Math.floor(Date.now() / 1000) + + const signature = await getSignature({ + path: '/tmp/a.txt', + operation: 'read', + user: 'user', + envdAccessToken: 'access-token', + expirationInSeconds: 0, + }) + + const after = Math.floor(Date.now() / 1000) + + assert.isNotNull(signature.expiration) + assert.isAtLeast(signature.expiration!, before) + assert.isAtMost(signature.expiration!, after) + }) + + test('zero signature expiration is included in URL', async () => { + const sandbox = createSandbox('access-token') + + for (const url of [ + await sandbox.downloadUrl('/tmp/a.txt', { useSignatureExpiration: 0 }), + await sandbox.uploadUrl('/tmp/a.txt', { useSignatureExpiration: 0 }), + ]) { + const expiration = new URL(url).searchParams.get('signature_expiration') + assert.isNotNull(expiration) + assert.approximately( + parseInt(expiration!), + Math.floor(Date.now() / 1000), + 5 + ) + } + }) +}) diff --git a/packages/js-sdk/tests/setup.ts b/packages/js-sdk/tests/setup.ts new file mode 100644 index 0000000..45e1573 --- /dev/null +++ b/packages/js-sdk/tests/setup.ts @@ -0,0 +1,167 @@ +import { test as base, onTestFailed } from 'vitest' +import { + BuildInfo, + LogEntry, + Sandbox, + SandboxOpts, + Template, + TemplateClass, + Volume, +} from '../src' +import { template } from './template' + +interface SandboxFixture { + sandbox: Sandbox + template: string + sandboxTestId: string + sandboxOpts: Partial +} + +interface VolumeFixture { + volume: Volume +} + +interface BuildTemplateFixture { + buildTemplate: ( + template: TemplateClass, + options?: { name?: string; skipCache?: boolean }, + onBuildLogs?: (logEntry: LogEntry) => void + ) => Promise +} + +async function buildTemplate( + template: TemplateClass, + options?: { name?: string; skipCache?: boolean }, + onBuildLogs?: (logEntry: LogEntry) => void +): Promise { + const buildName = options?.name || `e2b-test-${generateRandomString()}` + const buildInfo: { templateId?: string; buildId?: string } = {} + + const captureLogs = (log: LogEntry) => { + if (log.message.includes('Template created with ID:')) { + const match = log.message.match( + /Template created with ID: ([^,]+), Build ID: (.+)/ + ) + if (match) { + buildInfo.templateId = match[1] + buildInfo.buildId = match[2] + } + } + onBuildLogs?.(log) + } + + try { + return await Template.build(template, buildName, { + cpuCount: 1, + memoryMB: 1024, + skipCache: options?.skipCache, + onBuildLogs: captureLogs, + }) + } catch (e) { + console.error( + `\n[BUILD FAILED] name=${buildName}, ` + + `template_id=${buildInfo.templateId}, ` + + `build_id=${buildInfo.buildId}, error=${e}` + ) + throw e + } +} + +export const sandboxTest = base.extend({ + template, + sandboxTestId: [ + // eslint-disable-next-line no-empty-pattern + async ({}, use) => { + const id = `test-${generateRandomString()}` + await use(id) + }, + { auto: true }, + ], + sandboxOpts: {}, + sandbox: [ + async ({ sandboxTestId, sandboxOpts }, use) => { + const sandbox = await Sandbox.create(template, { + metadata: { sandboxTestId }, + ...sandboxOpts, + }) + onTestFailed(() => { + console.error(`\n[TEST FAILED] Sandbox ID: ${sandbox.sandboxId}`) + }) + try { + await use(sandbox) + } finally { + try { + await sandbox.kill() + } catch (err) { + if (!isDebug) { + console.warn( + 'Failed to kill sandbox — this is expected if the test runs with local envd.' + ) + } + } + } + }, + { auto: false }, + ], +}) + +export const buildTemplateTest = base.extend({ + buildTemplate: [ + // eslint-disable-next-line no-empty-pattern + async ({}, use) => { + await use(buildTemplate) + }, + { auto: true }, + ], +}) + +export const volumeTest = base + .extend({ + volume: [ + // eslint-disable-next-line no-empty-pattern + async ({}, use) => { + const volume = await Volume.create(`test-vol-${generateRandomString()}`) + onTestFailed(() => { + console.error(`\n[TEST FAILED] Volume ID: ${volume.volumeId}`) + }) + try { + await use(volume) + } finally { + try { + await Volume.destroy(volume.volumeId) + } catch { + // Ignore cleanup errors + } + } + }, + { auto: false }, + ], + }) + .skipIf(process.env.ENABLE_VOLUME_TESTS === undefined) + +export const isDebug = process.env.E2B_DEBUG !== undefined +export const isIntegrationTest = process.env.E2B_INTEGRATION_TEST !== undefined + +/** Placeholder API key with a valid format for tests that don't hit the API. */ +export const TEST_API_KEY = `e2b_${'0'.repeat(40)}` + +function generateRandomString(length: number = 8): string { + return Math.random() + .toString(36) + .substring(2, length + 2) +} + +export async function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * Returns the API URL for the given path, using E2B_DOMAIN env var. + * Supports msw path parameters like :templateID + */ +export function apiUrl(path: string): string { + const domain = process.env.E2B_DOMAIN || 'e2b.app' + return `https://api.${domain}${path}` +} + +export { template } diff --git a/packages/js-sdk/tests/template.ts b/packages/js-sdk/tests/template.ts new file mode 100644 index 0000000..388a5aa --- /dev/null +++ b/packages/js-sdk/tests/template.ts @@ -0,0 +1 @@ +export const template = 'base' diff --git a/packages/js-sdk/tests/template/abortSignal.test.ts b/packages/js-sdk/tests/template/abortSignal.test.ts new file mode 100644 index 0000000..9d54e32 --- /dev/null +++ b/packages/js-sdk/tests/template/abortSignal.test.ts @@ -0,0 +1,197 @@ +import { afterAll, afterEach, beforeAll, expect, test } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +import { Template } from '../../src' +import { TEST_API_KEY, apiUrl } from '../setup' + +// Hold the request open until the caller aborts. If the signal is already +// aborted by the time the handler runs, `addEventListener('abort', …)` would +// never fire — so check `aborted` first to avoid hanging. +function holdUntilAborted(signal: AbortSignal): Promise { + return new Promise((_, reject) => { + const abort = () => reject(new DOMException('aborted', 'AbortError')) + if (signal.aborted) { + abort() + return + } + signal.addEventListener('abort', abort, { once: true }) + }) +} + +const restHandlers = [ + http.post(apiUrl('/v3/templates'), async ({ request }) => { + await holdUntilAborted(request.signal) + return HttpResponse.json({}) + }), + http.get(apiUrl('/templates/aliases/:alias'), async ({ request }) => { + await holdUntilAborted(request.signal) + return HttpResponse.json({}) + }), + http.get( + apiUrl('/templates/:templateID/builds/:buildID/status'), + async ({ request }) => { + await holdUntilAborted(request.signal) + return HttpResponse.json({}) + } + ), + http.post(apiUrl('/templates/tags'), async ({ request }) => { + await holdUntilAborted(request.signal) + return HttpResponse.json({}) + }), + http.delete(apiUrl('/templates/tags'), async ({ request }) => { + await holdUntilAborted(request.signal) + return HttpResponse.json({}) + }), + http.get(apiUrl('/templates/:templateID/tags'), async ({ request }) => { + await holdUntilAborted(request.signal) + return HttpResponse.json([]) + }), +] + +const server = setupServer(...restHandlers) + +beforeAll(() => + server.listen({ + onUnhandledRequest: 'bypass', + }) +) + +afterAll(() => server.close()) + +afterEach(() => server.resetHandlers()) + +// Resolves once MSW has dispatched the next request, so tests can abort +// deterministically instead of guessing with `setTimeout`. +function nextRequestStart(): Promise { + return new Promise((resolve) => { + const listener = () => { + server.events.removeListener('request:start', listener) + resolve() + } + server.events.on('request:start', listener) + }) +} + +test('Template.build rejects when AbortSignal is aborted', async () => { + const controller = new AbortController() + const requestStarted = nextRequestStart() + + const template = Template().fromBaseImage() + const promise = Template.build(template, 'test-template', { + apiKey: TEST_API_KEY, + signal: controller.signal, + }) + + await requestStarted + controller.abort() + + await expect(promise).rejects.toThrow() +}) + +test('Template.build rejects immediately when signal is already aborted', async () => { + const controller = new AbortController() + controller.abort() + + const template = Template().fromBaseImage() + await expect( + Template.build(template, 'test-template', { + apiKey: TEST_API_KEY, + signal: controller.signal, + }) + ).rejects.toThrow() +}) + +test('Template.buildInBackground rejects when AbortSignal is aborted', async () => { + const controller = new AbortController() + const requestStarted = nextRequestStart() + + const template = Template().fromBaseImage() + const promise = Template.buildInBackground(template, 'test-template', { + apiKey: TEST_API_KEY, + signal: controller.signal, + }) + + await requestStarted + controller.abort() + + await expect(promise).rejects.toThrow() +}) + +test('Template.exists rejects when AbortSignal is aborted', async () => { + const controller = new AbortController() + const requestStarted = nextRequestStart() + + const promise = Template.exists('some-template', { + apiKey: TEST_API_KEY, + signal: controller.signal, + }) + + await requestStarted + controller.abort() + + await expect(promise).rejects.toThrow() +}) + +test('Template.getBuildStatus rejects when AbortSignal is aborted', async () => { + const controller = new AbortController() + const requestStarted = nextRequestStart() + + const promise = Template.getBuildStatus( + { templateId: 'tpl-1', buildId: 'build-1' }, + { + apiKey: TEST_API_KEY, + signal: controller.signal, + } + ) + + await requestStarted + controller.abort() + + await expect(promise).rejects.toThrow() +}) + +test('Template.assignTags rejects when AbortSignal is aborted', async () => { + const controller = new AbortController() + const requestStarted = nextRequestStart() + + const promise = Template.assignTags('some-template:v1', 'stable', { + apiKey: TEST_API_KEY, + signal: controller.signal, + }) + + await requestStarted + controller.abort() + + await expect(promise).rejects.toThrow() +}) + +test('Template.removeTags rejects when AbortSignal is aborted', async () => { + const controller = new AbortController() + const requestStarted = nextRequestStart() + + const promise = Template.removeTags('some-template', 'stable', { + apiKey: TEST_API_KEY, + signal: controller.signal, + }) + + await requestStarted + controller.abort() + + await expect(promise).rejects.toThrow() +}) + +test('Template.getTags rejects when AbortSignal is aborted', async () => { + const controller = new AbortController() + const requestStarted = nextRequestStart() + + const promise = Template.getTags('some-template', { + apiKey: TEST_API_KEY, + signal: controller.signal, + }) + + await requestStarted + controller.abort() + + await expect(promise).rejects.toThrow() +}) diff --git a/packages/js-sdk/tests/template/backgroundBuild.test.ts b/packages/js-sdk/tests/template/backgroundBuild.test.ts new file mode 100644 index 0000000..0e798b4 --- /dev/null +++ b/packages/js-sdk/tests/template/backgroundBuild.test.ts @@ -0,0 +1,25 @@ +import { randomUUID } from 'node:crypto' +import { expect, test } from 'vitest' +import { Template, waitForTimeout } from '../../src' + +test('build template in background', async () => { + const template = Template() + .fromImage('ubuntu:22.04') + .skipCache() + .runCmd('sleep 5') // Add a delay to ensure build takes time + .setStartCmd('echo "Hello"', waitForTimeout(10_000)) + + const name = `e2b-test:v1-${randomUUID()}` + + const buildInfo = await Template.buildInBackground(template, name, { + cpuCount: 1, + memoryMB: 1024, + }) + + // Should return quickly (within a few seconds), not wait for the full build + expect(buildInfo).toBeDefined() + + // Verify the build is actually running + const status = await Template.getBuildStatus(buildInfo) + expect(status.status).toEqual('building') +}, 10_000) diff --git a/packages/js-sdk/tests/template/build.test.ts b/packages/js-sdk/tests/template/build.test.ts new file mode 100644 index 0000000..241e8f1 --- /dev/null +++ b/packages/js-sdk/tests/template/build.test.ts @@ -0,0 +1,74 @@ +import fs from 'node:fs' +import path from 'node:path' +import { afterAll, beforeAll } from 'vitest' +import { defaultBuildLogger, Template, waitForTimeout } from '../../src' +import { buildTemplateTest } from '../setup' + +const folderPath = path.join(__dirname, 'folder') + +beforeAll(async () => { + fs.mkdirSync(folderPath, { recursive: true }) + fs.writeFileSync(path.join(folderPath, 'test.txt'), 'This is a test file.') + + // Create relative symlink + fs.symlinkSync('test.txt', path.join(folderPath, 'symlink.txt')) + + // Create absolute symlink + fs.symlinkSync( + path.join(folderPath, 'test.txt'), + path.join(folderPath, 'symlink2.txt') + ) + + // Create a symlink to a file that does not exist + fs.symlinkSync('12345test.txt', path.join(folderPath, 'symlink3.txt')) +}) + +afterAll(() => { + fs.rmSync(folderPath, { recursive: true }) +}) + +buildTemplateTest('build template', async ({ buildTemplate }) => { + const template = Template() + // using base image to avoid re-building ubuntu:22.04 image + .fromBaseImage() + .copy('folder/*', 'folder', { forceUpload: true }) + .runCmd('cat folder/test.txt') + .setWorkdir('/app') + .setStartCmd('echo "Hello, world!"', waitForTimeout(10_000)) + + await buildTemplate(template, { skipCache: true }, defaultBuildLogger()) +}) + +buildTemplateTest( + 'build template from base template', + async ({ buildTemplate }) => { + const template = Template().fromTemplate('base') + await buildTemplate(template, { skipCache: true }) + } +) + +buildTemplateTest('build template with symlinks', async ({ buildTemplate }) => { + const template = Template() + .fromImage('ubuntu:22.04') + .skipCache() + .copy('folder/*', 'folder', { forceUpload: true }) + .runCmd('cat folder/symlink.txt') + + await buildTemplate(template) +}) + +buildTemplateTest( + 'build template with resolveSymlinks', + async ({ buildTemplate }) => { + const template = Template() + .fromImage('ubuntu:22.04') + .skipCache() + .copy('folder/symlink.txt', 'folder/symlink.txt', { + forceUpload: true, + resolveSymlinks: true, + }) + .runCmd('cat folder/symlink.txt') + + await buildTemplate(template) + } +) diff --git a/packages/js-sdk/tests/template/exists.test.ts b/packages/js-sdk/tests/template/exists.test.ts new file mode 100644 index 0000000..aa62857 --- /dev/null +++ b/packages/js-sdk/tests/template/exists.test.ts @@ -0,0 +1,14 @@ +import { randomUUID } from 'node:crypto' +import { expect, test } from 'vitest' +import { Template } from '../../src' + +test('check if base template name exists', async () => { + const exists = await Template.exists('base') + expect(exists).toBe(true) +}) + +test('check non existing name', async () => { + const nonExistingName = `nonexistent-${randomUUID()}` + const exists = await Template.exists(nonExistingName) + expect(exists).toBe(false) +}) diff --git a/packages/js-sdk/tests/template/methods/fromDockerfile.test.ts b/packages/js-sdk/tests/template/methods/fromDockerfile.test.ts new file mode 100644 index 0000000..3b2f725 --- /dev/null +++ b/packages/js-sdk/tests/template/methods/fromDockerfile.test.ts @@ -0,0 +1,198 @@ +import { buildTemplateTest } from '../../setup' +import { Template } from '../../../src' +import { InstructionType } from '../../../src/template/types' +import { assert } from 'vitest' + +buildTemplateTest('fromDockerfile', async () => { + const dockerfile = `FROM node:24 +WORKDIR /app +COPY package.json . +RUN npm install +ENTRYPOINT ["sleep", "20"]` + + const template = Template().fromDockerfile(dockerfile) + + assert.equal( + // @ts-expect-error - baseImage is not a property of TemplateBuilder + template.baseImage, + 'node:24' + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[1].type, + InstructionType.WORKDIR + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[1].args[0], + '/' + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[2].type, + InstructionType.WORKDIR + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[2].args[0], + '/app' + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[3].type, + InstructionType.COPY + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[3].args[0], + 'package.json' + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[3].args[1], + '.' + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[4].type, + InstructionType.RUN + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[4].args[0], + 'npm install' + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[5].type, + InstructionType.USER + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[5].args[0], + 'user' + ) + assert.equal( + // @ts-expect-error - startCmd is not a property of TemplateBuilder + template.startCmd, + 'sleep 20' + ) +}) + +buildTemplateTest('fromDockerfile with default user and workdir', () => { + const dockerfile = 'FROM node:24' + const template = Template().fromDockerfile(dockerfile) + + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[template.instructions.length - 2].type, + InstructionType.USER + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[template.instructions.length - 2].args[0], + 'user' + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[template.instructions.length - 1].type, + InstructionType.WORKDIR + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[template.instructions.length - 1].args[0], + '/home/user' + ) +}) + +buildTemplateTest('fromDockerfile with custom user and workdir', () => { + const dockerfile = 'FROM node:24\nUSER mish\nWORKDIR /home/mish' + const template = Template().fromDockerfile(dockerfile) + + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[template.instructions.length - 2].type, + InstructionType.USER + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[template.instructions.length - 2].args[0], + 'mish' + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[template.instructions.length - 1].type, + InstructionType.WORKDIR + ) + assert.equal( + // @ts-expect-error - instructions is not a property of TemplateBuilder + template.instructions[template.instructions.length - 1].args[0], + '/home/mish' + ) +}) + +buildTemplateTest('fromDockerfile with multi-source COPY', () => { + const dockerfile = `FROM node:24 +COPY file1.txt file2.txt file3.txt /dest/` + + const template = Template().fromDockerfile(dockerfile) + + // After initial USER root and WORKDIR /, the multi-source COPY should + // expand into one COPY instruction per source. + // @ts-expect-error - instructions is not a property of TemplateBuilder + const copyInstructions = template.instructions.filter( + (i: { type: InstructionType }) => i.type === InstructionType.COPY + ) + + assert.equal(copyInstructions.length, 3) + assert.equal(copyInstructions[0].args[0], 'file1.txt') + assert.equal(copyInstructions[0].args[1], '/dest/') + assert.equal(copyInstructions[1].args[0], 'file2.txt') + assert.equal(copyInstructions[1].args[1], '/dest/') + assert.equal(copyInstructions[2].args[0], 'file3.txt') + assert.equal(copyInstructions[2].args[1], '/dest/') +}) + +buildTemplateTest('fromDockerfile with multi-source COPY --chown', () => { + const dockerfile = `FROM node:24 +COPY --chown=myuser:mygroup pkg.json pkg-lock.json /app/` + + const template = Template().fromDockerfile(dockerfile) + + // @ts-expect-error - instructions is not a property of TemplateBuilder + const copyInstructions = template.instructions.filter( + (i: { type: InstructionType }) => i.type === InstructionType.COPY + ) + + assert.equal(copyInstructions.length, 2) + assert.equal(copyInstructions[0].args[0], 'pkg.json') + assert.equal(copyInstructions[0].args[1], '/app/') + assert.equal(copyInstructions[0].args[2], 'myuser:mygroup') + assert.equal(copyInstructions[1].args[0], 'pkg-lock.json') + assert.equal(copyInstructions[1].args[1], '/app/') + assert.equal(copyInstructions[1].args[2], 'myuser:mygroup') +}) + +buildTemplateTest('fromDockerfile with COPY --chown', () => { + const dockerfile = `FROM node:24 +COPY --chown=myuser:mygroup app.js /app/ +COPY --chown=anotheruser config.json /config/` + + const template = Template().fromDockerfile(dockerfile) + + // First COPY instruction (after initial USER root and WORKDIR /) + // @ts-expect-error - instructions is not a property of TemplateBuilder + const copyInstruction1 = template.instructions[2] + assert.equal(copyInstruction1.type, InstructionType.COPY) + assert.equal(copyInstruction1.args[0], 'app.js') + assert.equal(copyInstruction1.args[1], '/app/') + assert.equal(copyInstruction1.args[2], 'myuser:mygroup') // user from --chown + + // Second COPY instruction + // @ts-expect-error - instructions is not a property of TemplateBuilder + const copyInstruction2 = template.instructions[3] + assert.equal(copyInstruction2.type, InstructionType.COPY) + assert.equal(copyInstruction2.args[0], 'config.json') + assert.equal(copyInstruction2.args[1], '/config/') + assert.equal(copyInstruction2.args[2], 'anotheruser') // user from --chown (without group) +}) diff --git a/packages/js-sdk/tests/template/methods/makeSymlink.test.ts b/packages/js-sdk/tests/template/methods/makeSymlink.test.ts new file mode 100644 index 0000000..67a1c81 --- /dev/null +++ b/packages/js-sdk/tests/template/methods/makeSymlink.test.ts @@ -0,0 +1,23 @@ +import { Template } from '../../../src' +import { buildTemplateTest } from '../../setup' + +buildTemplateTest('make symlink', async ({ buildTemplate }) => { + const template = Template() + .fromImage('ubuntu:22.04') + .skipCache() + .makeSymlink('.bashrc', '.bashrc.local') + .runCmd('test "$(readlink .bashrc.local)" = ".bashrc"') + + await buildTemplate(template) +}) + +buildTemplateTest('make symlink (force)', async ({ buildTemplate }) => { + const template = Template() + .fromImage('ubuntu:22.04') + .makeSymlink('.bashrc', '.bashrc.local') + .skipCache() + .makeSymlink('.bashrc', '.bashrc.local', { force: true }) // Overwrite existing symlink + .runCmd('test "$(readlink .bashrc.local)" = ".bashrc"') + + await buildTemplate(template) +}) diff --git a/packages/js-sdk/tests/template/methods/runCmd.test.ts b/packages/js-sdk/tests/template/methods/runCmd.test.ts new file mode 100644 index 0000000..340a226 --- /dev/null +++ b/packages/js-sdk/tests/template/methods/runCmd.test.ts @@ -0,0 +1,38 @@ +import { expect } from 'vitest' +import { Template } from '../../../src' +import { buildTemplateTest } from '../../setup' + +buildTemplateTest('run command', async ({ buildTemplate }) => { + const template = Template() + .fromImage('ubuntu:22.04') + .skipCache() + .runCmd('ls -l') + + await buildTemplate(template) +}) + +buildTemplateTest( + 'run command as a different user', + async ({ buildTemplate }) => { + const template = Template() + .fromImage('ubuntu:22.04') + .skipCache() + .runCmd('test "$(whoami)" = "root"', { user: 'root' }) + + await buildTemplate(template) + } +) + +buildTemplateTest( + 'run command as user that does not exist', + async ({ buildTemplate }) => { + const template = Template() + .fromImage('ubuntu:22.04') + .skipCache() + .runCmd('whoami', { user: 'root123' }) + + await expect(buildTemplate(template)).rejects.toThrow( + "failed to run command 'whoami': command failed: unauthenticated: invalid username: 'root123'" + ) + } +) diff --git a/packages/js-sdk/tests/template/methods/toDockerfile.test.ts b/packages/js-sdk/tests/template/methods/toDockerfile.test.ts new file mode 100644 index 0000000..37f68bd --- /dev/null +++ b/packages/js-sdk/tests/template/methods/toDockerfile.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from 'vitest' +import { Template } from '../../../src' + +test('toDockerfile', { timeout: 180000 }, async () => { + const template = Template() + .fromUbuntuImage('24.04') + .copy('README.md', '/app/README.md') + .runCmd('echo "Hello, World!"') + + const dockerfile = Template.toDockerfile(template) + + const expectedDockerfile = `FROM ubuntu:24.04 +COPY README.md /app/README.md +RUN echo "Hello, World!" +` + expect(dockerfile).toBe(expectedDockerfile) +}) + +test('toDockerfile with options', { timeout: 180000 }, async () => { + const template = Template() + .fromUbuntuImage('24.04') + .copy('README.md', '/app/README.md', { user: 'root' }) + .runCmd('echo "Hello, World!"', { user: 'root' }) + + const dockerfile = Template.toDockerfile(template) + + const expectedDockerfile = `FROM ubuntu:24.04 +COPY README.md /app/README.md +RUN echo "Hello, World!" +` + expect(dockerfile).toBe(expectedDockerfile) +}) + +test('toDockerfile with ENV instructions', { timeout: 180000 }, async () => { + const template = Template() + .fromUbuntuImage('24.04') + .setEnvs({ NODE_ENV: 'production', PORT: '8080' }) + .setEnvs({ DEBUG: 'false' }) + + const dockerfile = Template.toDockerfile(template) + + const expectedDockerfile = `FROM ubuntu:24.04 +ENV NODE_ENV=production PORT=8080 +ENV DEBUG=false +` + expect(dockerfile).toBe(expectedDockerfile) +}) diff --git a/packages/js-sdk/tests/template/stacktrace.test.ts b/packages/js-sdk/tests/template/stacktrace.test.ts new file mode 100644 index 0000000..69bab8b --- /dev/null +++ b/packages/js-sdk/tests/template/stacktrace.test.ts @@ -0,0 +1,434 @@ +import fs from 'node:fs' +import { assert, afterAll, afterEach, beforeAll } from 'vitest' + +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +import { Template, waitForTimeout } from '../../src' +import { apiUrl, buildTemplateTest } from '../setup' +import { randomUUID } from 'node:crypto' + +const __fileContent = fs.readFileSync(__filename, 'utf8') // read current file content +const nonExistentPath = 'nonexistent/path' + +// map template alias -> failed step index +const failureMap: Record = { + fromImage: 0, + fromTemplate: 0, + fromDockerfile: 0, + fromImageRegistry: 0, + fromAWSRegistry: 0, + fromGCPRegistry: 0, + copy: undefined, + copyItems: undefined, + // multi-source copy produces two COPY instructions (steps 1 and 2), + // the runCmd after it is step 3 + multiSourceCopySecondSource: 2, + multiSourceCopyNextStep: 3, + copyItemsSecondItem: 2, + copyItemsNextStep: 3, + remove: 1, + rename: 1, + makeDir: 1, + makeSymlink: 1, + runCmd: 1, + setWorkdir: 1, + setUser: 1, + pipInstall: 1, + npmInstall: 1, + aptInstall: 1, + gitClone: 1, + setStartCmd: 1, + addMcpServer: undefined, + betaDevContainerPrebuild: 1, + betaSetDevContainerStart: 1, +} + +export const restHandlers = [ + http.post(apiUrl('/v3/templates'), async ({ request }) => { + const { name } = (await request.clone().json()) as { name: string } + return HttpResponse.json({ + buildID: randomUUID(), + templateID: name, + tags: [], + }) + }), + http.post(apiUrl('/v2/templates/:templateID/builds/:buildID'), () => { + return HttpResponse.json({}) + }), + http.get(apiUrl('/templates/:templateID/files/:hash'), () => { + return HttpResponse.json({ present: true }) + }), + http.get<{ templateID: string; buildID: string }>( + apiUrl('/templates/:templateID/builds/:buildID/status'), + ({ params }) => { + const { templateID } = params + return HttpResponse.json({ + status: 'error', + reason: { + message: 'Mocked API build error', + step: failureMap[templateID], + }, + logEntries: [], + }) + } + ), +] + +const server = setupServer(...restHandlers) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + +afterAll(() => server.close()) + +afterEach(() => server.resetHandlers()) + +function getStackTraceCallerMethod( + fileContent: string, + stackTrace: string | undefined +) { + if (!stackTrace) { + return null + } + + const stackTraceLines = stackTrace.split('\n') + if (stackTraceLines.length === 0) { + return null + } + const callerTrace = stackTraceLines[0] + + // Match line and column numbers at the end of the stack trace line + // Format: ...file.ts:123:45) or ...file.ts:123:45 + // This handles Windows paths (C:\Users\...) and Unix paths + const lineColumnMatch = callerTrace.match(/:(\d+):(\d+)\)?$/) + if (!lineColumnMatch) { + return null + } + const lineNumber = parseInt(lineColumnMatch[1]) + const columnNumber = parseInt(lineColumnMatch[2]) + + const lines = fileContent.split('\n') + const parsedLine = lines[lineNumber - 1] + if (!parsedLine) { + return null + } + + // Extract the method name from the line + const methodNameMatch = parsedLine + .slice(columnNumber - 1) + .match(/^(\w+)\s*\(/) + if (methodNameMatch) { + return methodNameMatch[1] + } + return null +} + +async function expectToThrowAndCheckTrace( + func: (...args: any[]) => Promise, + expectedMethod: string +) { + try { + await func() + assert.fail('Expected Template.build to throw an error') + } catch (error) { + const callerMethod = getStackTraceCallerMethod(__fileContent, error.stack) + if (!callerMethod) { + throw error + } + assert.include(callerMethod, expectedMethod) + } +} + +buildTemplateTest('traces on fromImage', async ({ buildTemplate }) => { + const template = Template().fromImage('e2b.dev/this-image-does-not-exist') + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'fromImage', skipCache: true }) + }, 'fromImage') +}) + +buildTemplateTest('traces on fromTemplate', async ({ buildTemplate }) => { + const template = Template().fromTemplate('this-template-does-not-exist') + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'fromTemplate', skipCache: true }) + }, 'fromTemplate') +}) + +buildTemplateTest('traces on fromDockerfile', async ({ buildTemplate }) => { + const template = Template().fromDockerfile( + 'FROM ubuntu:22.04\nRUN nonexistent' + ) + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'fromDockerfile', skipCache: true }) + }, 'fromDockerfile') +}) + +buildTemplateTest('traces on fromImage registry', async ({ buildTemplate }) => { + const template = Template().fromImage( + 'registry.example.com/nonexistent:latest', + { + username: 'test', + password: 'test', + } + ) + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { + name: 'fromImageRegistry', + }) + }, 'fromImage') +}) + +buildTemplateTest('traces on fromAWSRegistry', async ({ buildTemplate }) => { + const template = Template().fromAWSRegistry( + '123456789.dkr.ecr.us-east-1.amazonaws.com/nonexistent:latest', + { + accessKeyId: 'test', + secretAccessKey: 'test', + region: 'us-east-1', + } + ) + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'fromAWSRegistry' }) + }, 'fromAWSRegistry') +}) + +buildTemplateTest('traces on fromGCPRegistry', async ({ buildTemplate }) => { + const template = Template().fromGCPRegistry( + 'gcr.io/nonexistent-project/nonexistent:latest', + { + serviceAccountJSON: { type: 'service_account' }, + } + ) + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'fromGCPRegistry' }) + }, 'fromGCPRegistry') +}) + +buildTemplateTest('traces on fromImage credentials', async () => { + await expectToThrowAndCheckTrace(async () => { + // @ts-expect-error - testing runtime validation with partial credentials + Template().fromImage('ubuntu:22.04', { username: 'user' }) + }, 'fromImage') +}) + +buildTemplateTest('traces on copy', async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template.skipCache().copy(nonExistentPath, nonExistentPath) + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'copy' }) + }, 'copy') +}) + +buildTemplateTest('traces on copyItems', async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template + .skipCache() + .copyItems([{ src: nonExistentPath, dest: nonExistentPath }]) + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'copyItems' }) + }, 'copyItems') +}) + +buildTemplateTest( + 'traces on second source of multi-source copy', + async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template.copy(['stacktrace.test.ts', 'tags.test.ts'], '.') + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'multiSourceCopySecondSource' }) + }, 'copy') + } +) + +buildTemplateTest( + 'traces on step after multi-source copy', + async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template + .copy(['stacktrace.test.ts', 'tags.test.ts'], '.') + .runCmd(`./${nonExistentPath}`) + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'multiSourceCopyNextStep' }) + }, 'runCmd') + } +) + +buildTemplateTest( + 'traces on second item of copyItems', + async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template.copyItems([ + { src: 'stacktrace.test.ts', dest: '.' }, + { src: 'tags.test.ts', dest: '.' }, + ]) + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'copyItemsSecondItem' }) + }, 'copyItems') + } +) + +buildTemplateTest( + 'traces on step after copyItems', + async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template + .copyItems([ + { src: 'stacktrace.test.ts', dest: '.' }, + { src: 'tags.test.ts', dest: '.' }, + ]) + .runCmd(`./${nonExistentPath}`) + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'copyItemsNextStep' }) + }, 'runCmd') + } +) + +buildTemplateTest('traces on copy absolute path', async () => { + await expectToThrowAndCheckTrace(async () => { + Template().fromBaseImage().copy('/absolute/path', '/absolute/path') + }, 'copy') +}) + +buildTemplateTest('traces on copyItems absolute path', async () => { + await expectToThrowAndCheckTrace(async () => { + Template() + .fromBaseImage() + .copyItems([{ src: '/absolute/path', dest: '/absolute/path' }]) + }, 'copyItems') +}) + +buildTemplateTest('traces on remove', async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template.skipCache().remove(nonExistentPath) + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'remove' }) + }, 'remove') +}) + +buildTemplateTest('traces on rename', async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template.skipCache().rename(nonExistentPath, '/tmp/dest.txt') + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'rename' }) + }, 'rename') +}) + +buildTemplateTest('traces on makeDir', async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template.skipCache().makeDir('.bashrc') + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'makeDir' }) + }, 'makeDir') +}) + +buildTemplateTest('traces on makeSymlink', async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template.skipCache().makeSymlink('.bashrc', '.bashrc') + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'makeSymlink' }) + }, 'makeSymlink') +}) + +buildTemplateTest('traces on runCmd', async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template.skipCache().runCmd(`./${nonExistentPath}`) + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'runCmd' }) + }, 'runCmd') +}) + +buildTemplateTest('traces on setWorkdir', async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template.skipCache().setWorkdir('/root/.bashrc') + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'setWorkdir' }) + }, 'setWorkdir') +}) + +buildTemplateTest('traces on setUser', async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template.skipCache().setUser('; exit 1') + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'setUser' }) + }, 'setUser') +}) + +buildTemplateTest('traces on pipInstall', async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template.skipCache().pipInstall('nonexistent-package') + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'pipInstall' }) + }, 'pipInstall') +}) + +buildTemplateTest('traces on npmInstall', async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template.skipCache().npmInstall('nonexistent-package') + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'npmInstall' }) + }, 'npmInstall') +}) + +buildTemplateTest('traces on aptInstall', async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template.skipCache().aptInstall('nonexistent-package') + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'aptInstall' }) + }, 'aptInstall') +}) + +buildTemplateTest('traces on gitClone', async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template + .skipCache() + .gitClone('https://github.com/nonexistent/repo.git') + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'gitClone' }) + }, 'gitClone') +}) + +buildTemplateTest('traces on setStartCmd', async ({ buildTemplate }) => { + let template: any = Template().fromBaseImage() + template = template.setStartCmd( + `./${nonExistentPath}`, + waitForTimeout(10_000) + ) + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { name: 'setStartCmd' }) + }, 'setStartCmd') +}) + +buildTemplateTest('traces on addMcpServer', async () => { + // needs mcp-gateway as base template, without it no mcp servers can be added + await expectToThrowAndCheckTrace(async () => { + Template().fromBaseImage().skipCache().addMcpServer('exa') + }, 'addMcpServer') +}) + +buildTemplateTest( + 'traces on betaDevContainerPrebuild', + async ({ buildTemplate }) => { + const template = Template() + .fromTemplate('devcontainer') + .skipCache() + .betaDevContainerPrebuild(nonExistentPath) + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { + name: 'betaDevContainerPrebuild', + }) + }, 'betaDevContainerPrebuild') + } +) + +buildTemplateTest( + 'traces on betaSetDevContainerStart', + async ({ buildTemplate }) => { + const template = Template() + .fromTemplate('devcontainer') + .betaSetDevContainerStart(nonExistentPath) + await expectToThrowAndCheckTrace(async () => { + await buildTemplate(template, { + name: 'betaSetDevContainerStart', + }) + }, 'betaSetDevContainerStart') + } +) diff --git a/packages/js-sdk/tests/template/tags.test.ts b/packages/js-sdk/tests/template/tags.test.ts new file mode 100644 index 0000000..21e2166 --- /dev/null +++ b/packages/js-sdk/tests/template/tags.test.ts @@ -0,0 +1,202 @@ +import { randomUUID } from 'node:crypto' +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest' + +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +import { Template } from '../../src' +import { apiUrl, buildTemplateTest, isDebug } from '../setup' + +// Mock handlers for tag API endpoints +const mockHandlers = [ + http.post(apiUrl('/templates/tags'), async ({ request }) => { + const { tags } = (await request.clone().json()) as { + tags: string[] + } + return HttpResponse.json({ + buildID: '00000000-0000-0000-0000-000000000000', + tags: tags, + }) + }), + // Get template tags endpoint + http.get(apiUrl('/templates/:templateID/tags'), ({ params }) => { + const { templateID } = params + if (templateID === 'nonexistent') { + return HttpResponse.json( + { message: 'Template not found' }, + { status: 404 } + ) + } + return HttpResponse.json([ + { + tag: 'v1.0', + buildID: '00000000-0000-0000-0000-000000000000', + createdAt: '2024-01-15T10:30:00Z', + }, + { + tag: 'latest', + buildID: '11111111-1111-1111-1111-111111111111', + createdAt: '2024-01-16T12:00:00Z', + }, + ]) + }), + // Bulk delete endpoint + http.delete(apiUrl('/templates/tags'), async ({ request }) => { + const { name } = (await request.clone().json()) as { + name: string + tags: string[] + } + if (name === 'nonexistent') { + return HttpResponse.json( + { message: 'Template not found' }, + { status: 404 } + ) + } + return new HttpResponse(null, { status: 204 }) + }), +] + +const server = setupServer(...mockHandlers) + +// Unit tests with mock server +describe('Template tags unit tests', () => { + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + afterAll(() => server.close()) + afterEach(() => server.resetHandlers()) + + describe('Template.assignTags', () => { + test('assigns a single tag', async () => { + const result = await Template.assignTags('my-template:v1.0', 'production') + expect(result.buildId).toBe('00000000-0000-0000-0000-000000000000') + expect(result.tags).toContain('production') + }) + + test('assigns multiple tags', async () => { + const result = await Template.assignTags('my-template:v1.0', [ + 'production', + 'stable', + ]) + expect(result.buildId).toBe('00000000-0000-0000-0000-000000000000') + expect(result.tags).toContain('production') + expect(result.tags).toContain('stable') + }) + }) + + describe('Template.removeTags', () => { + test('deletes a single tag', async () => { + // Should not throw + await expect( + Template.removeTags('my-template', 'production') + ).resolves.toBeUndefined() + }) + + test('deletes multiple tags', async () => { + // Should not throw + await expect( + Template.removeTags('my-template', ['production', 'staging']) + ).resolves.toBeUndefined() + }) + + test('handles 404 error for nonexistent template', async () => { + await expect( + Template.removeTags('nonexistent', ['tag']) + ).rejects.toThrow() + }) + }) + + describe('Template.getTags', () => { + test('returns tags for a template', async () => { + const tags = await Template.getTags('my-template-id') + expect(tags).toHaveLength(2) + expect(tags[0].tag).toBe('v1.0') + expect(tags[0].buildId).toBe('00000000-0000-0000-0000-000000000000') + expect(tags[0].createdAt).toBeInstanceOf(Date) + expect(tags[1].tag).toBe('latest') + expect(tags[1].buildId).toBe('11111111-1111-1111-1111-111111111111') + expect(tags[1].createdAt).toBeInstanceOf(Date) + }) + + test('handles 404 for nonexistent template', async () => { + await expect(Template.getTags('nonexistent')).rejects.toThrow() + }) + }) +}) + +// Integration tests +buildTemplateTest.skipIf(isDebug)( + 'build template with tags, assign and delete', + { timeout: 300_000 }, + async ({ buildTemplate }) => { + const templateName = 'e2b-tags-test' + const initialTag = `${templateName}:v1-${randomUUID()}` + + // Build a template with initial tag + const template = Template().fromBaseImage() + const buildInfo = await buildTemplate(template, { name: initialTag }) + + expect(buildInfo.buildId).toBeTruthy() + expect(buildInfo.templateId).toBeTruthy() + + // Assign additional tags (just tag names, not full alias:tag format) + const tagInfo = await Template.assignTags(initialTag, [ + 'production', + 'latest', + ]) + + expect(tagInfo.buildId).toBeTruthy() + expect(tagInfo.tags).toContain('production') + expect(tagInfo.tags).toContain('latest') + } +) + +buildTemplateTest.skipIf(isDebug)( + 'assign single tag to existing template', + { timeout: 300_000 }, + async ({ buildTemplate }) => { + const templateName = 'e2b-tags-test' + const initialTag = `${templateName}:v1-${randomUUID()}` + + const template = Template().fromBaseImage() + await buildTemplate(template, { name: initialTag }) + + // Assign single tag (just tag name, not full alias:tag format) + const tagInfo = await Template.assignTags(initialTag, 'stable') + + expect(tagInfo.buildId).toBeTruthy() + expect(tagInfo.tags).toContain('stable') + } +) + +buildTemplateTest.skipIf(isDebug)( + 'rejects invalid tag format - missing alias', + { timeout: 300_000 }, + async ({ buildTemplate }) => { + const templateName = 'e2b-tags-test' + const initialTag = `${templateName}:v1-${randomUUID()}` + + const template = Template().fromBaseImage() + await buildTemplate(template, { name: initialTag }) + + // Tag without alias (starts with colon) should be rejected + await expect( + Template.assignTags(initialTag, ':invalid-tag') + ).rejects.toThrow() + } +) + +buildTemplateTest.skipIf(isDebug)( + 'rejects invalid tag format - missing tag', + { timeout: 300_000 }, + async ({ buildTemplate }) => { + const templateName = 'e2b-tags-test' + const initialTag = `${templateName}:v1-${randomUUID()}` + + const template = Template().fromBaseImage() + await buildTemplate(template, { name: initialTag }) + + // Tag without tag portion (ends with colon) should be rejected + await expect( + Template.assignTags(initialTag, `${templateName}:`) + ).rejects.toThrow() + } +) diff --git a/packages/js-sdk/tests/template/uploadFile.test.ts b/packages/js-sdk/tests/template/uploadFile.test.ts new file mode 100644 index 0000000..280b959 --- /dev/null +++ b/packages/js-sdk/tests/template/uploadFile.test.ts @@ -0,0 +1,70 @@ +import { describe, test, expect, beforeAll, afterAll } from 'vitest' +import { writeFile, mkdtemp, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { createServer, type IncomingMessage, type Server } from 'http' +import { AddressInfo } from 'net' +import { uploadFile } from '../../src/template/buildApi' + +// Regression test for e2b-dev/e2b#1243 — uploadFile used to pass a Node +// Readable directly to fetch, which made undici fall back to +// Transfer-Encoding: chunked. S3 presigned PUT URLs reject that with 501 +// NotImplemented. The fix spools the archive to a temporary file and streams +// it from disk with an explicit Content-Length. +describe('uploadFile transfer encoding', () => { + let testDir: string + let server: Server + let baseUrl: string + let capturedHeaders: IncomingMessage['headers'] = {} + let capturedBodyLength = 0 + + beforeAll(async () => { + testDir = await mkdtemp(join(tmpdir(), 'uploadFile-test-')) + await writeFile(join(testDir, 'hello.txt'), 'hello world') + + server = createServer((req, res) => { + capturedHeaders = req.headers + let bytes = 0 + req.on('data', (chunk: Buffer) => { + bytes += chunk.length + }) + req.on('end', () => { + capturedBodyLength = bytes + res.writeHead(200) + res.end() + }) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + baseUrl = `http://127.0.0.1:${port}/upload` + }) + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())) + await rm(testDir, { recursive: true, force: true }) + }) + + test('sets Content-Length and does not use chunked transfer encoding', async () => { + await uploadFile( + { + fileName: '*.txt', + fileContextPath: testDir, + url: baseUrl, + ignorePatterns: [], + resolveSymlinks: false, + gzip: true, + }, + undefined + ) + + expect(capturedHeaders['content-length']).toBeDefined() + const contentLength = Number(capturedHeaders['content-length']) + expect(contentLength).toBeGreaterThan(0) + expect(contentLength).toBe(capturedBodyLength) + + const transferEncoding = capturedHeaders['transfer-encoding'] + if (transferEncoding !== undefined) { + expect(transferEncoding.toLowerCase()).not.toContain('chunked') + } + }) +}) diff --git a/packages/js-sdk/tests/template/utils/getAllFilesInPath.test.ts b/packages/js-sdk/tests/template/utils/getAllFilesInPath.test.ts new file mode 100644 index 0000000..fefc0e9 --- /dev/null +++ b/packages/js-sdk/tests/template/utils/getAllFilesInPath.test.ts @@ -0,0 +1,328 @@ +import { expect, test, describe, beforeAll, afterAll, beforeEach } from 'vitest' +import { writeFile, mkdir, rm } from 'fs/promises' +import { join, basename } from 'path' +import { getAllFilesInPath } from '../../../src/template/utils' + +describe('getAllFilesInPath', () => { + const testDir = join(__dirname, 'folder') + + beforeAll(async () => { + await rm(testDir, { recursive: true, force: true }) + await mkdir(testDir, { recursive: true }) + }) + + afterAll(async () => { + await rm(testDir, { recursive: true, force: true }) + }) + + beforeEach(async () => { + await rm(testDir, { recursive: true, force: true }) + await mkdir(testDir, { recursive: true }) + }) + + test('should return files matching a simple pattern', async () => { + // Create test files + await writeFile(join(testDir, 'file1.txt'), 'content1') + await writeFile(join(testDir, 'file2.txt'), 'content2') + await writeFile(join(testDir, 'file3.js'), 'content3') + + const files = await getAllFilesInPath('*.txt', testDir, []) + + expect(files).toHaveLength(2) + expect(files.some((f) => f.fullpath().endsWith('file1.txt'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('file2.txt'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('file3.js'))).toBe(false) + }) + + test('should handle directory patterns recursively', async () => { + // Create nested directory structure + await mkdir(join(testDir, 'src'), { recursive: true }) + await mkdir(join(testDir, 'src', 'components'), { recursive: true }) + await mkdir(join(testDir, 'src', 'utils'), { recursive: true }) + + await writeFile(join(testDir, 'src', 'index.ts'), 'index content') + await writeFile( + join(testDir, 'src', 'components', 'Button.tsx'), + 'button content' + ) + await writeFile( + join(testDir, 'src', 'utils', 'helper.ts'), + 'helper content' + ) + await writeFile(join(testDir, 'README.md'), 'readme content') + + const files = await getAllFilesInPath('src', testDir, []) + + expect(files).toHaveLength(6) // 3 files + 3 directories (src, components, utils) + expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('Button.tsx'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('helper.ts'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('README.md'))).toBe(false) + }) + + test('should respect ignore patterns', async () => { + // Create test files + await writeFile(join(testDir, 'file1.txt'), 'content1') + await writeFile(join(testDir, 'file2.txt'), 'content2') + await writeFile(join(testDir, 'temp.txt'), 'temp content') + await writeFile(join(testDir, 'backup.txt'), 'backup content') + + const files = await getAllFilesInPath('*.txt', testDir, [ + 'temp*', + 'backup*', + ]) + + expect(files).toHaveLength(2) + expect(files.some((f) => f.fullpath().endsWith('file1.txt'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('file2.txt'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('temp.txt'))).toBe(false) + expect(files.some((f) => f.fullpath().endsWith('backup.txt'))).toBe(false) + }) + + test('should handle complex ignore patterns', async () => { + // Create nested structure with various file types + await mkdir(join(testDir, 'src'), { recursive: true }) + await mkdir(join(testDir, 'src', 'components'), { recursive: true }) + await mkdir(join(testDir, 'src', 'utils'), { recursive: true }) + await mkdir(join(testDir, 'tests'), { recursive: true }) + + await writeFile(join(testDir, 'src', 'index.ts'), 'index content') + await writeFile( + join(testDir, 'src', 'components', 'Button.tsx'), + 'button content' + ) + await writeFile( + join(testDir, 'src', 'utils', 'helper.ts'), + 'helper content' + ) + await writeFile(join(testDir, 'tests', 'test.spec.ts'), 'test content') + await writeFile( + join(testDir, 'src', 'components', 'Button.test.tsx'), + 'test content' + ) + await writeFile( + join(testDir, 'src', 'utils', 'helper.spec.ts'), + 'spec content' + ) + + const files = await getAllFilesInPath('src', testDir, [ + '**/*.test.*', + '**/*.spec.*', + ]) + + expect(files).toHaveLength(6) // 3 files + 3 directories (src, components, utils) + expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('Button.tsx'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('helper.ts'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('Button.test.tsx'))).toBe( + false + ) + expect(files.some((f) => f.fullpath().endsWith('helper.spec.ts'))).toBe( + false + ) + }) + + test('should handle empty directories', async () => { + await mkdir(join(testDir, 'empty'), { recursive: true }) + await writeFile(join(testDir, 'file.txt'), 'content') + + const files = await getAllFilesInPath('empty', testDir, []) + + expect(files).toHaveLength(1) // The empty directory itself + }) + + test('should handle mixed files and directories', async () => { + // Create a mix of files and directories + await writeFile(join(testDir, 'file1.txt'), 'content1') + await mkdir(join(testDir, 'dir1'), { recursive: true }) + await writeFile(join(testDir, 'dir1', 'file2.txt'), 'content2') + await writeFile(join(testDir, 'file3.txt'), 'content3') + + const files = await getAllFilesInPath('*', testDir, []) + + expect(files).toHaveLength(4) // 3 files + 1 directory + expect(files.some((f) => f.fullpath().endsWith('file1.txt'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('file2.txt'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('file3.txt'))).toBe(true) + }) + + test('should handle glob patterns with subdirectories', async () => { + // Create nested structure + await mkdir(join(testDir, 'src'), { recursive: true }) + await mkdir(join(testDir, 'src', 'components'), { recursive: true }) + await mkdir(join(testDir, 'src', 'utils'), { recursive: true }) + + await writeFile(join(testDir, 'src', 'index.ts'), 'index content') + await writeFile( + join(testDir, 'src', 'components', 'Button.tsx'), + 'button content' + ) + await writeFile( + join(testDir, 'src', 'utils', 'helper.ts'), + 'helper content' + ) + await writeFile( + join(testDir, 'src', 'components', 'Button.css'), + 'css content' + ) + + const files = await getAllFilesInPath('src/**/*', testDir, []) + + expect(files).toHaveLength(6) // 4 files + 2 directories (components, utils) + expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('Button.tsx'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('helper.ts'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('Button.css'))).toBe(true) + }) + + test('should handle specific file extensions', async () => { + await writeFile(join(testDir, 'file1.ts'), 'ts content') + await writeFile(join(testDir, 'file2.js'), 'js content') + await writeFile(join(testDir, 'file3.tsx'), 'tsx content') + await writeFile(join(testDir, 'file4.css'), 'css content') + + const files = await getAllFilesInPath('*.ts', testDir, []) + + expect(files).toHaveLength(1) + expect(files.some((f) => f.fullpath().endsWith('file1.ts'))).toBe(true) + }) + + test('should return sorted files', async () => { + await writeFile(join(testDir, 'zebra.txt'), 'z content') + await writeFile(join(testDir, 'apple.txt'), 'a content') + await writeFile(join(testDir, 'banana.txt'), 'b content') + + const files = await getAllFilesInPath('*.txt', testDir, []) + + expect(files).toHaveLength(3) + // Files must be returned sorted by full path so the files hash is + // independent of filesystem traversal order + const fileNames = files.map((f) => basename(f.fullpath())) + expect(fileNames).toEqual(['apple.txt', 'banana.txt', 'zebra.txt']) + }) + + test('should return nested files sorted by full path', async () => { + await mkdir(join(testDir, 'b'), { recursive: true }) + await mkdir(join(testDir, 'a'), { recursive: true }) + await writeFile(join(testDir, 'zebra.txt'), 'z content') + await writeFile(join(testDir, 'b', 'file.txt'), 'b content') + await writeFile(join(testDir, 'a', 'file.txt'), 'a content') + + const files = await getAllFilesInPath('*', testDir, []) + + const paths = files.map((f) => f.fullpath()) + expect(paths).toEqual([...paths].sort()) + }) + + test('should handle no matching files', async () => { + await writeFile(join(testDir, 'file.txt'), 'content') + + const files = await getAllFilesInPath('*.js', testDir, []) + + expect(files).toHaveLength(0) + }) + + test('should include dotfiles', async () => { + // Create regular and dotfiles + await writeFile(join(testDir, 'file.txt'), 'content') + await writeFile(join(testDir, '.env'), 'SECRET=123') + await writeFile(join(testDir, '.gitignore'), 'node_modules') + + const files = await getAllFilesInPath('*', testDir, []) + + expect(files).toHaveLength(3) + expect(files.some((f) => f.fullpath().endsWith('file.txt'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('.env'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('.gitignore'))).toBe(true) + }) + + test('should include dotfiles in subdirectories', async () => { + await mkdir(join(testDir, 'src'), { recursive: true }) + await writeFile(join(testDir, 'src', 'index.ts'), 'content') + await writeFile(join(testDir, 'src', '.env.local'), 'SECRET=123') + + const files = await getAllFilesInPath('src', testDir, []) + + expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('.env.local'))).toBe(true) + }) + + test('should include dotdirectories and their contents', async () => { + await mkdir(join(testDir, '.hidden'), { recursive: true }) + await writeFile(join(testDir, '.hidden', 'config.json'), '{}') + await writeFile(join(testDir, 'visible.txt'), 'content') + + const files = await getAllFilesInPath('*', testDir, []) + + expect(files.some((f) => f.fullpath().endsWith('.hidden'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('config.json'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('visible.txt'))).toBe(true) + }) + + test('should respect ignore patterns for dotfiles', async () => { + await writeFile(join(testDir, '.env'), 'SECRET=123') + await writeFile(join(testDir, '.gitignore'), 'node_modules') + await writeFile(join(testDir, 'file.txt'), 'content') + + const files = await getAllFilesInPath('*', testDir, ['.env']) + + expect(files).toHaveLength(2) + expect(files.some((f) => f.fullpath().endsWith('.env'))).toBe(false) + expect(files.some((f) => f.fullpath().endsWith('.gitignore'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('file.txt'))).toBe(true) + }) + + test('should handle listing all files in current directory with dot pattern', async () => { + // Create a small directory tree inside the test directory + await writeFile(join(testDir, 'root.txt'), 'root') + await mkdir(join(testDir, 'subdir'), { recursive: true }) + await writeFile(join(testDir, 'subdir', 'nested.txt'), 'nested') + + const files = await getAllFilesInPath('.', testDir, []) + + // We should get at least the testDir itself (.) plus its children + expect(files.length).toBeGreaterThanOrEqual(3) + + // All returned paths must stay within the testDir - we must not traverse the whole filesystem + const allWithinTestDir = files.every((f) => + f.fullpath().startsWith(testDir) + ) + expect(allWithinTestDir).toBe(true) + }) + + test('should handle complex ignore patterns with directories', async () => { + // Create a complex structure + await mkdir(join(testDir, 'src'), { recursive: true }) + await mkdir(join(testDir, 'src', 'components'), { recursive: true }) + await mkdir(join(testDir, 'src', 'utils'), { recursive: true }) + await mkdir(join(testDir, 'src', 'tests'), { recursive: true }) + await mkdir(join(testDir, 'dist'), { recursive: true }) + + await writeFile(join(testDir, 'src', 'index.ts'), 'index content') + await writeFile( + join(testDir, 'src', 'components', 'Button.tsx'), + 'button content' + ) + await writeFile( + join(testDir, 'src', 'utils', 'helper.ts'), + 'helper content' + ) + await writeFile( + join(testDir, 'src', 'tests', 'test.spec.ts'), + 'test content' + ) + await writeFile(join(testDir, 'dist', 'bundle.js'), 'bundle content') + await writeFile(join(testDir, 'README.md'), 'readme content') + + const files = await getAllFilesInPath('src', testDir, [ + '**/tests/**', + '**/*.spec.*', + ]) + + expect(files).toHaveLength(6) // 3 files + 3 directories (src, components, utils) + expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('Button.tsx'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('helper.ts'))).toBe(true) + expect(files.some((f) => f.fullpath().endsWith('test.spec.ts'))).toBe(false) + }) +}) diff --git a/packages/js-sdk/tests/template/utils/getCallerDirectory.test.ts b/packages/js-sdk/tests/template/utils/getCallerDirectory.test.ts new file mode 100644 index 0000000..8bbfdda --- /dev/null +++ b/packages/js-sdk/tests/template/utils/getCallerDirectory.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from 'vitest' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { getCallerDirectory } from '../../../src/template/utils' + +test('getCallerDirectory', () => { + // getCallerDirectory(1) should return the directory of the current file + // __dirname is the directory of the current file. + // Normalize before comparing: on Windows the stack-trace file name reported + // by vite/vitest uses forward slashes (e.g. "D:/a/...") whereas __dirname + // uses native backslashes — the directory is the same, only the separators + // differ. + expect(path.normalize(getCallerDirectory(1)!)).toBe(path.normalize(__dirname)) +}) + +test('getCallerDirectory handles file:// URLs from ESM modules', () => { + // In ESM modules, CallSite.getFileName() returns file:// URLs + // This test verifies that the conversion works correctly + const testDirectoryPath = path.join(os.tmpdir(), 'test', 'project', 'src') + const testFilePath = path.join(testDirectoryPath, 'template.ts') + const fileUrl = pathToFileURL(testFilePath) + + // Verify that fileURLToPath correctly converts the URL + // This is the same conversion used in getCallerDirectory + expect(fileURLToPath(fileUrl)).toBe(testFilePath) + expect(path.dirname(fileURLToPath(fileUrl))).toBe(testDirectoryPath) +}) diff --git a/packages/js-sdk/tests/template/utils/normalizeBuildArguments.test.ts b/packages/js-sdk/tests/template/utils/normalizeBuildArguments.test.ts new file mode 100644 index 0000000..294ee45 --- /dev/null +++ b/packages/js-sdk/tests/template/utils/normalizeBuildArguments.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'vitest' +import { normalizeBuildArguments } from '../../../src/template/utils' +import { TemplateError } from '../../../src/errors' + +describe('normalizeBuildArguments', () => { + test('handles string name', () => { + const result = normalizeBuildArguments('my-template:v1.0') + expect(result.name).toBe('my-template:v1.0') + }) + + test('handles string name with options', () => { + const result = normalizeBuildArguments('my-template:v1.0', { cpuCount: 4 }) + expect(result.name).toBe('my-template:v1.0') + expect(result.buildOptions).toEqual({ cpuCount: 4 }) + }) + + test('handles legacy options with alias', () => { + const result = normalizeBuildArguments({ alias: 'my-template' }) + expect(result.name).toBe('my-template') + }) + + test('removes alias from build options', () => { + const result = normalizeBuildArguments({ + alias: 'my-template', + cpuCount: 4, + }) + expect(result.name).toBe('my-template') + expect(result.buildOptions).toEqual({ cpuCount: 4 }) + expect(result.buildOptions).not.toHaveProperty('alias') + }) + + test('throws for empty name', () => { + expect(() => normalizeBuildArguments({ alias: '' })).toThrow(TemplateError) + }) + + test('throws for missing name', () => { + expect(() => normalizeBuildArguments({} as any)).toThrow(TemplateError) + }) +}) diff --git a/packages/js-sdk/tests/template/utils/readycmd.test.ts b/packages/js-sdk/tests/template/utils/readycmd.test.ts new file mode 100644 index 0000000..f3da4f1 --- /dev/null +++ b/packages/js-sdk/tests/template/utils/readycmd.test.ts @@ -0,0 +1,49 @@ +import { expect, test, describe } from 'vitest' +import { + waitForFile, + waitForPort, + waitForProcess, + waitForTimeout, + waitForURL, +} from '../../../src/template/readycmd' + +describe('readycmd', () => { + test('waitForPort matches the exact listening port', () => { + const cmd = waitForPort(80).getCmd() + expect(cmd).toBe('[ -n "$(ss -Htuln sport = :80)" ]') + }) + + test('waitForURL quotes the url', () => { + const cmd = waitForURL('http://localhost:3000/health?ready=1&x=y').getCmd() + expect(cmd).toBe( + 'curl -s -o /dev/null -w "%{http_code}" \'http://localhost:3000/health?ready=1&x=y\' | grep -q "200"' + ) + }) + + test('waitForURL keeps simple urls unquoted', () => { + const cmd = waitForURL('http://localhost:3000/health').getCmd() + expect(cmd).toBe( + 'curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/health | grep -q "200"' + ) + }) + + test('waitForProcess quotes the process name', () => { + const cmd = waitForProcess('my daemon').getCmd() + expect(cmd).toBe("pgrep 'my daemon' > /dev/null") + }) + + test('waitForFile quotes the filename', () => { + const cmd = waitForFile('/tmp/ready file').getCmd() + expect(cmd).toBe("[ -f '/tmp/ready file' ]") + }) + + test('waitForFile keeps simple paths unquoted', () => { + const cmd = waitForFile('/tmp/ready').getCmd() + expect(cmd).toBe('[ -f /tmp/ready ]') + }) + + test('waitForTimeout converts milliseconds to seconds', () => { + expect(waitForTimeout(5000).getCmd()).toBe('sleep 5') + expect(waitForTimeout(100).getCmd()).toBe('sleep 1') + }) +}) diff --git a/packages/js-sdk/tests/template/utils/tarFileStream.test.ts b/packages/js-sdk/tests/template/utils/tarFileStream.test.ts new file mode 100644 index 0000000..762fc6d --- /dev/null +++ b/packages/js-sdk/tests/template/utils/tarFileStream.test.ts @@ -0,0 +1,314 @@ +import { + expect, + test, + describe, + beforeAll, + afterAll, + beforeEach, + vi, +} from 'vitest' +import { writeFile, mkdir, rm, symlink, readFile, access } from 'fs/promises' +import fs from 'node:fs' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { tarFileStream } from '../../../src/template/utils' +import * as tar from 'tar' +import { ReadEntry } from 'tar' + +describe('tarFileStream', () => { + const testDir = join(__dirname, 'tar-test-folder') + + beforeAll(async () => { + await rm(testDir, { recursive: true, force: true }) + await mkdir(testDir, { recursive: true }) + }) + + afterAll(async () => { + await rm(testDir, { recursive: true, force: true }) + }) + + beforeEach(async () => { + await rm(testDir, { recursive: true, force: true }) + await mkdir(testDir, { recursive: true }) + }) + + /** + * Wait until a path no longer exists. Cleanup runs asynchronously from the + * stream's `close` handler, so it may not complete in the same tick. + */ + async function waitUntilGone(target: string): Promise { + for (let i = 0; i < 100; i++) { + try { + await access(target) + } catch { + return + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } + throw new Error(`path was not removed: ${target}`) + } + + /** + * Pipe an archive stream into tar's extractor and collect its entries. + */ + async function extractTarStream( + stream: Readable + ): Promise<{ extractDir: string; members: ReadEntry[] }> { + const extractDir = join( + tmpdir(), + `tar-extract-${Date.now()}-${Math.random()}` + ) + await mkdir(extractDir, { recursive: true }) + + const members: ReadEntry[] = [] + await pipeline( + stream, + tar.extract({ + cwd: extractDir, + gzip: true, + onentry: (entry: ReadEntry) => { + members.push(entry) + }, + }) + ) + + return { extractDir, members } + } + + /** + * Extract archive contents into a map of path -> file contents. + */ + async function extractTarContents( + stream: Readable + ): Promise> { + const { extractDir, members } = await extractTarStream(stream) + const contents = new Map() + + for (const member of members) { + if (member.type === 'File') { + try { + contents.set( + member.path, + await readFile(join(extractDir, member.path)) + ) + } catch { + // File might not exist + } + } else if (member.type === 'Directory') { + contents.set(member.path, null) + } else if (member.type === 'SymbolicLink') { + contents.set(member.path, Buffer.from(member.linkpath || '')) + } + } + + await rm(extractDir, { recursive: true, force: true }) + return contents + } + + /** + * Extract archive members into a map of path -> entry. + */ + async function getTarMembers( + stream: Readable + ): Promise> { + const { extractDir, members } = await extractTarStream(stream) + const map = new Map() + for (const member of members) { + map.set(member.path, member) + } + await rm(extractDir, { recursive: true, force: true }) + return map + } + + test('should create tar with simple files', async () => { + await writeFile(join(testDir, 'file1.txt'), 'content1') + await writeFile(join(testDir, 'file2.txt'), 'content2') + + const { stream, size } = await tarFileStream( + '*.txt', + testDir, + [], + false, + true + ) + expect(size).toBeGreaterThan(0) + + const contents = await extractTarContents(stream) + + expect(contents.size).toBe(2) + expect(contents.get('file1.txt')?.toString()).toBe('content1') + expect(contents.get('file2.txt')?.toString()).toBe('content2') + }) + + test('should create an uncompressed tar when gzip is disabled', async () => { + await writeFile(join(testDir, 'file1.txt'), 'content1') + await writeFile(join(testDir, 'file2.txt'), 'content2') + + const { stream } = await tarFileStream('*.txt', testDir, [], false, false) + + // Buffer the archive so we can both assert it is not gzipped and extract it + const chunks: Buffer[] = [] + for await (const chunk of stream as unknown as AsyncIterable) { + chunks.push(Buffer.from(chunk)) + } + const archive = Buffer.concat(chunks) + + // gzip streams start with the magic bytes 0x1f 0x8b — an uncompressed tar must not + expect(archive[0]).not.toBe(0x1f) + expect(archive.subarray(0, 2).equals(Buffer.from([0x1f, 0x8b]))).toBe(false) + + // And it should still extract to the original files + const tarFile = join(tmpdir(), `tar-uncompressed-${Date.now()}.tar`) + await writeFile(tarFile, archive) + const extractDir = join(tmpdir(), `tar-uncompressed-extract-${Date.now()}`) + await mkdir(extractDir, { recursive: true }) + await tar.extract({ file: tarFile, cwd: extractDir }) + expect((await readFile(join(extractDir, 'file1.txt'))).toString()).toBe( + 'content1' + ) + expect((await readFile(join(extractDir, 'file2.txt'))).toString()).toBe( + 'content2' + ) + await rm(tarFile, { force: true }) + await rm(extractDir, { recursive: true, force: true }) + }) + + test('should respect ignore patterns', async () => { + await writeFile(join(testDir, 'file1.txt'), 'content1') + await writeFile(join(testDir, 'file2.txt'), 'content2') + await writeFile(join(testDir, 'temp.txt'), 'temp content') + await writeFile(join(testDir, 'backup.txt'), 'backup content') + + const { stream } = await tarFileStream( + '*.txt', + testDir, + ['temp*', 'backup*'], + false, + true + ) + + const contents = await extractTarContents(stream) + + expect(contents.size).toBe(2) + expect(contents.get('file1.txt')?.toString()).toBe('content1') + expect(contents.get('file2.txt')?.toString()).toBe('content2') + expect(contents.has('temp.txt')).toBe(false) + expect(contents.has('backup.txt')).toBe(false) + }) + + test('should handle nested files', async () => { + const nestedDir = join(testDir, 'src', 'components') + await mkdir(nestedDir, { recursive: true }) + + await writeFile(join(testDir, 'src', 'index.ts'), 'index content') + await writeFile(join(nestedDir, 'Button.tsx'), 'button content') + + const { stream } = await tarFileStream('src', testDir, [], false, true) + + const contents = await extractTarContents(stream) + const paths = Array.from(contents.keys()) + expect(paths.some((p) => p.includes('src'))).toBe(true) + expect(paths.some((p) => p.includes('index.ts'))).toBe(true) + expect(paths.some((p) => p.includes('Button.tsx'))).toBe(true) + }) + + test('should resolve symlinks when enabled', async () => { + await writeFile(join(testDir, 'original.txt'), 'original content') + + try { + await symlink('original.txt', join(testDir, 'link.txt')) + } catch (error: any) { + // Skip test if symlinks are not supported on this platform + if (error.code === 'ENOSYS' || error.code === 'EPERM') { + return + } + throw error + } + + // Test with resolveSymlinks=true + const { stream } = await tarFileStream('*.txt', testDir, [], true, true) + + const contents = await extractTarContents(stream) + expect(contents.get('original.txt')?.toString()).toBe('original content') + // Symlink should be resolved (contain actual content, not link) + expect(contents.get('link.txt')?.toString()).toBe('original content') + }) + + test('should preserve symlinks when disabled', async () => { + await writeFile(join(testDir, 'original.txt'), 'original content') + + try { + await symlink('original.txt', join(testDir, 'link.txt')) + } catch (error: any) { + // Skip test if symlinks are not supported on this platform + if (error.code === 'ENOSYS' || error.code === 'EPERM') { + return + } + throw error + } + + // Test with resolveSymlinks=false + const { stream } = await tarFileStream('*.txt', testDir, [], false, true) + + const members = await getTarMembers(stream) + expect(members.get('original.txt')?.type).toBe('File') + const link = members.get('link.txt')! + expect(link.type).toBe('SymbolicLink') + expect(link.linkpath).toBe('original.txt') + }) + + test('removes the spooled archive once the stream is consumed', async () => { + await writeFile(join(testDir, 'file1.txt'), 'content1') + + // Capture the temp dir the helper creates so we can assert it is gone. + let tmpDir: string | undefined + const mkdtemp = fs.promises.mkdtemp.bind(fs.promises) + const spy = vi + .spyOn(fs.promises, 'mkdtemp') + .mockImplementation(async (prefix: any, ...rest: any[]) => { + const dir = await mkdtemp(prefix, ...rest) + tmpDir = dir + return dir + }) + + try { + const { stream } = await tarFileStream('*.txt', testDir, [], false) + expect(tmpDir).toBeDefined() + await access(tmpDir!) + + // Drain the stream to completion; the `close` handler then removes the + // spooled archive. + stream.resume() + await waitUntilGone(tmpDir!) + } finally { + spy.mockRestore() + } + }) + + test('cleans up the spooled archive if it is never consumed', async () => { + await writeFile(join(testDir, 'file1.txt'), 'content1') + + let tmpDir: string | undefined + const mkdtemp = fs.promises.mkdtemp.bind(fs.promises) + const spy = vi + .spyOn(fs.promises, 'mkdtemp') + .mockImplementation(async (prefix: any, ...rest: any[]) => { + const dir = await mkdtemp(prefix, ...rest) + tmpDir = dir + return dir + }) + + try { + const { stream } = await tarFileStream('*.txt', testDir, [], false) + await access(tmpDir!) + + // Destroying without reading still fires `close` and removes the file. + stream.destroy() + await waitUntilGone(tmpDir!) + } finally { + spy.mockRestore() + } + }) +}) diff --git a/packages/js-sdk/tests/template/utils/validateRelativePath.test.ts b/packages/js-sdk/tests/template/utils/validateRelativePath.test.ts new file mode 100644 index 0000000..7c6e22e --- /dev/null +++ b/packages/js-sdk/tests/template/utils/validateRelativePath.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from 'vitest' +import { validateRelativePath } from '../../../src/template/utils' +import { TemplateError } from '../../../src/errors' + +const isWindows = process.platform === 'win32' + +describe('validateRelativePath', () => { + describe('valid paths', () => { + test('accepts simple relative path', () => { + expect(() => validateRelativePath('foo', undefined)).not.toThrow() + }) + + test('accepts nested relative path', () => { + expect(() => validateRelativePath('foo/bar', undefined)).not.toThrow() + }) + + test('accepts path with ./ prefix', () => { + expect(() => validateRelativePath('./foo', undefined)).not.toThrow() + }) + + test('accepts nested path with ./ prefix', () => { + expect(() => validateRelativePath('./foo/bar', undefined)).not.toThrow() + }) + + test('accepts path with internal parent ref that stays within context', () => { + expect(() => validateRelativePath('foo/../bar', undefined)).not.toThrow() + }) + + test('accepts current directory', () => { + expect(() => validateRelativePath('.', undefined)).not.toThrow() + }) + + test('accepts glob patterns', () => { + expect(() => validateRelativePath('*.txt', undefined)).not.toThrow() + expect(() => validateRelativePath('**/*.ts', undefined)).not.toThrow() + expect(() => validateRelativePath('src/**/*', undefined)).not.toThrow() + }) + + test('accepts hidden files and directories', () => { + expect(() => validateRelativePath('.hidden', undefined)).not.toThrow() + expect(() => + validateRelativePath('.config/settings', undefined) + ).not.toThrow() + }) + + test('accepts filenames starting with double dots', () => { + expect(() => validateRelativePath('..myconfig', undefined)).not.toThrow() + expect(() => validateRelativePath('..cache', undefined)).not.toThrow() + expect(() => + validateRelativePath('...something', undefined) + ).not.toThrow() + expect(() => + validateRelativePath('foo/..myconfig', undefined) + ).not.toThrow() + }) + }) + + describe('invalid paths - absolute', () => { + test('rejects Unix absolute path', () => { + expect(() => validateRelativePath('/absolute/path', undefined)).toThrow( + TemplateError + ) + expect(() => validateRelativePath('/absolute/path', undefined)).toThrow( + 'absolute paths are not allowed' + ) + }) + + test('rejects root path', () => { + expect(() => validateRelativePath('/', undefined)).toThrow(TemplateError) + }) + + // Windows path tests - only run on Windows where path.isAbsolute detects them + test.skipIf(!isWindows)('rejects Windows drive letter path', () => { + expect(() => + validateRelativePath('C:\\Windows\\System32', undefined) + ).toThrow(TemplateError) + expect(() => + validateRelativePath('C:\\Windows\\System32', undefined) + ).toThrow('absolute paths are not allowed') + }) + + test.skipIf(!isWindows)('rejects Windows UNC path', () => { + expect(() => + validateRelativePath('\\\\server\\share', undefined) + ).toThrow(TemplateError) + }) + }) + + describe('invalid paths - parent directory escape', () => { + test('rejects simple parent directory escape', () => { + expect(() => validateRelativePath('../foo', undefined)).toThrow( + TemplateError + ) + expect(() => validateRelativePath('../foo', undefined)).toThrow( + 'path escapes the context directory' + ) + }) + + test('rejects parent directory escape with forward slash', () => { + expect(() => validateRelativePath('../file.txt', undefined)).toThrow( + TemplateError + ) + }) + + test.skipIf(!isWindows)( + 'rejects parent directory escape with backslash', + () => { + expect(() => validateRelativePath('..\\file.txt', undefined)).toThrow( + TemplateError + ) + } + ) + + test('rejects double parent directory escape', () => { + expect(() => validateRelativePath('../../foo', undefined)).toThrow( + TemplateError + ) + }) + + test('rejects path that escapes via nested parent refs', () => { + expect(() => validateRelativePath('foo/../../bar', undefined)).toThrow( + TemplateError + ) + }) + + test('rejects path with ./ prefix that escapes', () => { + expect(() => + validateRelativePath('./foo/../../../bar', undefined) + ).toThrow(TemplateError) + }) + + test('rejects just parent directory', () => { + expect(() => validateRelativePath('..', undefined)).toThrow(TemplateError) + }) + + test('rejects current directory followed by parent', () => { + expect(() => validateRelativePath('./..', undefined)).toThrow( + TemplateError + ) + }) + + test('rejects deeply nested escape', () => { + expect(() => + validateRelativePath('a/b/c/../../../../escape', undefined) + ).toThrow(TemplateError) + }) + }) + + describe('error messages include path', () => { + test('absolute path error includes the path', () => { + try { + validateRelativePath('/etc/passwd', undefined) + expect.fail('Should have thrown') + } catch (e) { + expect(e.message).toContain('/etc/passwd') + } + }) + + test('escape path error includes the path', () => { + try { + validateRelativePath('../secret', undefined) + expect.fail('Should have thrown') + } catch (e) { + expect(e.message).toContain('../secret') + } + }) + }) +}) diff --git a/packages/js-sdk/tests/volume/file.test.ts b/packages/js-sdk/tests/volume/file.test.ts new file mode 100644 index 0000000..5819b33 --- /dev/null +++ b/packages/js-sdk/tests/volume/file.test.ts @@ -0,0 +1,398 @@ +import { describe, expect } from 'vitest' +import { NotFoundError, VolumeError, VolumeFileType } from '../../src' +import { volumeTest } from '../setup' + +describe('Volume File Operations', () => { + describe('writeFile and readFile', () => { + volumeTest('should write and read a text file', async ({ volume }) => { + const path = '/test.txt' + const content = 'Hello, World!' + + const stat = await volume.writeFile(path, content) + expect(stat.name).toBe('test.txt') + expect(stat.type).toBe(VolumeFileType.FILE) + expect(stat.path).toBe(path) + expect(stat.atime).toBeInstanceOf(Date) + expect(stat.mtime).toBeInstanceOf(Date) + expect(stat.ctime).toBeInstanceOf(Date) + + const readContent = await volume.readFile(path, { format: 'text' }) + expect(readContent).toBe(content) + }) + + volumeTest( + 'should write and read a file with bytes format', + async ({ volume }) => { + const path = '/test-bytes.txt' + const content = 'Test bytes content' + const contentBytes = new TextEncoder().encode(content) + + await volume.writeFile(path, contentBytes.buffer) + const readBytes = await volume.readFile(path, { format: 'bytes' }) + + expect(readBytes).toEqual(contentBytes) + } + ) + + volumeTest('should write and read binary data', async ({ volume }) => { + const path = '/binary.bin' + const binaryData = new Uint8Array([0, 1, 2, 3, 4]) + + await volume.writeFile(path, binaryData.buffer) + const readBytes = await volume.readFile(path, { format: 'bytes' }) + + expect(readBytes).toEqual(binaryData) + }) + + volumeTest( + 'should write and read a file with blob format', + async ({ volume }) => { + const path = '/test-blob.txt' + const content = 'Test blob content' + const blob = new Blob([content], { type: 'text/plain' }) + + await volume.writeFile(path, blob) + const readBlob = await volume.readFile(path, { format: 'blob' }) + + expect(await readBlob.text()).toBe(content) + } + ) + + volumeTest( + 'should write and read a file from a ReadableStream', + async ({ volume }) => { + const path = '/test-stream.txt' + const content = 'Test stream content' + const stream = new Blob([content]).stream() + + await volume.writeFile(path, stream) + const readContent = await volume.readFile(path, { format: 'text' }) + + expect(readContent).toBe(content) + } + ) + + volumeTest('should write and read an empty file', async ({ volume }) => { + const path = '/empty.txt' + const content = '' + + await volume.writeFile(path, content) + const readContent = await volume.readFile(path, { format: 'text' }) + + expect(readContent).toBe(content) + }) + + volumeTest( + 'should read an empty file in all formats', + async ({ volume }) => { + const path = '/empty-formats.txt' + await volume.writeFile(path, '') + + const bytes = await volume.readFile(path, { format: 'bytes' }) + expect(bytes).toBeInstanceOf(Uint8Array) + expect(bytes.length).toBe(0) + + const blob = await volume.readFile(path, { format: 'blob' }) + expect(blob).toBeInstanceOf(Blob) + expect(blob.size).toBe(0) + + const stream = await volume.readFile(path, { format: 'stream' }) + expect(stream).toBeInstanceOf(ReadableStream) + const chunks: Uint8Array[] = [] + for await (const chunk of stream as unknown as AsyncIterable) { + chunks.push(chunk) + } + expect(chunks.reduce((n, c) => n + c.length, 0)).toBe(0) + } + ) + + volumeTest( + 'should overwrite an existing file with force option', + async ({ volume }) => { + const path = '/overwrite.txt' + const initialContent = 'Initial content' + const newContent = 'New content' + + await volume.writeFile(path, initialContent) + await volume.writeFile(path, newContent, { force: true }) + const readContent = await volume.readFile(path, { format: 'text' }) + + expect(readContent).toBe(newContent) + } + ) + + volumeTest( + 'should throw VolumeError when writing to existing file with force: false', + async ({ volume }) => { + const path = '/no-overwrite.txt' + const initialContent = 'Initial content' + const newContent = 'New content' + + await volume.writeFile(path, initialContent) + await expect( + volume.writeFile(path, newContent, { force: false }) + ).rejects.toThrow(VolumeError) + } + ) + + volumeTest( + 'should write file with metadata (uid, gid, mode)', + async ({ volume }) => { + const path = '/metadata.txt' + const content = 'File with metadata' + + const stat = await volume.writeFile(path, content, { + uid: 1000, + gid: 1000, + mode: 0o644, + }) + + expect(stat.type).toBe(VolumeFileType.FILE) + expect(stat.uid).toBe(1000) + expect(stat.gid).toBe(1000) + expect(stat.mode).toBe(0o644) + } + ) + + volumeTest('should write file in nested directory', async ({ volume }) => { + const dirPath = '/nested/deep/path' + const filePath = `${dirPath}/file.txt` + const content = 'Nested file content' + + await volume.makeDir(dirPath, { force: true }) + await volume.writeFile(filePath, content) + const readContent = await volume.readFile(filePath, { format: 'text' }) + + expect(readContent).toBe(content) + }) + }) + + describe('getInfo', () => { + volumeTest('should get info for a file', async ({ volume }) => { + const path = '/info-file.txt' + const content = 'File for info test' + + await volume.writeFile(path, content) + const info = await volume.getInfo(path) + + expect(info.name).toBe('info-file.txt') + expect(info.type).toBe(VolumeFileType.FILE) + expect(info.path).toBe(path) + expect(info.atime).toBeInstanceOf(Date) + expect(info.mtime).toBeInstanceOf(Date) + expect(info.ctime).toBeInstanceOf(Date) + }) + + volumeTest('should get info for a directory', async ({ volume }) => { + const path = '/info-dir' + + await volume.makeDir(path) + const info = await volume.getInfo(path) + + expect(info.name).toBe('info-dir') + expect(info.type).toBe(VolumeFileType.DIRECTORY) + expect(info.path).toBe(path) + }) + + volumeTest( + 'should return false from exists for non-existent file', + async ({ volume }) => { + expect(await volume.exists('/non-existent.txt')).toBe(false) + } + ) + }) + + describe('updateMetadata', () => { + volumeTest('should update file metadata', async ({ volume }) => { + const path = '/metadata-update.txt' + await volume.writeFile(path, 'Content') + + const updated = await volume.updateMetadata(path, { + uid: 1001, + gid: 1001, + mode: 0o755, + }) + + expect(updated.path).toBe(path) + expect(updated.type).toBe(VolumeFileType.FILE) + expect(updated.uid).toBe(1001) + expect(updated.gid).toBe(1001) + expect(updated.mode).toBe(0o755) + }) + + volumeTest( + 'should throw NotFoundError when updating non-existent file', + async ({ volume }) => { + await expect( + volume.updateMetadata('/non-existent.txt', { mode: 0o644 }) + ).rejects.toThrow(NotFoundError) + } + ) + }) + + describe('makeDir', () => { + volumeTest('should create a directory', async ({ volume }) => { + const path = '/test-dir' + + const stat = await volume.makeDir(path) + + expect(stat.type).toBe(VolumeFileType.DIRECTORY) + expect(stat.path).toBe(path) + expect(stat.atime).toBeInstanceOf(Date) + expect(stat.mtime).toBeInstanceOf(Date) + expect(stat.ctime).toBeInstanceOf(Date) + }) + + volumeTest( + 'should create nested directories with force option', + async ({ volume }) => { + const path = '/nested/deep/directory' + + const stat = await volume.makeDir(path, { force: true }) + + expect(stat.type).toBe(VolumeFileType.DIRECTORY) + } + ) + + volumeTest( + 'should throw VolumeError when creating existing directory with force: false', + async ({ volume }) => { + const path = '/existing-dir' + + await volume.makeDir(path) + await expect(volume.makeDir(path, { force: false })).rejects.toThrow( + VolumeError + ) + } + ) + + volumeTest('should create directory with metadata', async ({ volume }) => { + const path = '/dir-with-metadata' + + const stat = await volume.makeDir(path, { + uid: 1000, + gid: 1000, + mode: 0o755, + }) + + expect(stat.type).toBe(VolumeFileType.DIRECTORY) + expect(stat.uid).toBe(1000) + expect(stat.gid).toBe(1000) + expect(stat.mode & 0o777).toBe(0o755) + }) + }) + + describe('list', () => { + volumeTest('should list directory contents', async ({ volume }) => { + await volume.writeFile('/file1.txt', 'Content 1') + await volume.writeFile('/file2.txt', 'Content 2') + await volume.makeDir('/dir1') + + const entries = await volume.list('/') + + expect(entries.length).toBeGreaterThanOrEqual(3) + const fileNames = entries.map((e) => e.name).sort() + expect(fileNames).toContain('file1.txt') + expect(fileNames).toContain('file2.txt') + expect(fileNames).toContain('dir1') + }) + + volumeTest('should list nested directory contents', async ({ volume }) => { + await volume.makeDir('/nested', { force: true }) + await volume.writeFile('/nested/file.txt', 'Content') + + const entries = await volume.list('/nested') + + expect(entries.length).toBeGreaterThanOrEqual(1) + expect(entries.some((e) => e.name === 'file.txt')).toBe(true) + }) + + volumeTest.skip('should list with depth option', async ({ volume }) => { + await volume.makeDir('/deep/nested/structure', { force: true }) + await volume.writeFile('/deep/nested/structure/file.txt', 'Content') + + const entries = await volume.list('/deep', { depth: 2 }) + + expect(entries.length).toBeGreaterThan(0) + }) + + volumeTest( + 'should throw NotFoundError for non-existent directory', + async ({ volume }) => { + await expect(volume.list('/non-existent')).rejects.toThrow( + NotFoundError + ) + } + ) + }) + + describe('remove', () => { + volumeTest('should remove a file', async ({ volume }) => { + const path = '/to-remove.txt' + await volume.writeFile(path, 'Content') + + await volume.remove(path) + + expect(await volume.exists(path)).toBe(false) + }) + + volumeTest('should remove a directory', async ({ volume }) => { + const path = '/to-remove-dir' + await volume.makeDir(path) + + await volume.remove(path) + + expect(await volume.exists(path)).toBe(false) + }) + + volumeTest('should remove a directory recursively', async ({ volume }) => { + const dirPath = '/recursive-dir' + await volume.makeDir(`${dirPath}/nested`, { force: true }) + await volume.writeFile(`${dirPath}/nested/file.txt`, 'Content') + + await volume.remove(dirPath) + + expect(await volume.exists(dirPath)).toBe(false) + }) + + volumeTest( + 'should throw NotFoundError when removing non-existent file', + async ({ volume }) => { + await expect(volume.remove('/non-existent.txt')).rejects.toThrow( + NotFoundError + ) + } + ) + }) + + describe('file operations lifecycle', () => { + volumeTest( + 'should handle directory with multiple files', + async ({ volume }) => { + const dirPath = '/multi-file-dir' + await volume.makeDir(dirPath) + + const files = ['file1.txt', 'file2.txt', 'file3.txt'] + for (const fileName of files) { + await volume.writeFile( + `${dirPath}/${fileName}`, + `Content of ${fileName}` + ) + } + + const entries = await volume.list(dirPath) + expect(entries.length).toBeGreaterThanOrEqual(files.length) + + for (const fileName of files) { + const content = await volume.readFile(`${dirPath}/${fileName}`, { + format: 'text', + }) + expect(content).toBe(`Content of ${fileName}`) + } + + await volume.remove(dirPath) + expect(await volume.exists(dirPath)).toBe(false) + } + ) + }) +}) diff --git a/packages/js-sdk/tests/volume/volume.test.ts b/packages/js-sdk/tests/volume/volume.test.ts new file mode 100644 index 0000000..ff1d2f2 --- /dev/null +++ b/packages/js-sdk/tests/volume/volume.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, afterAll, afterEach, beforeAll } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { randomUUID } from 'node:crypto' + +import { Volume, NotFoundError, VolumeError } from '../../src' +import { VolumeConnectionConfig } from '../../src/volume/client' +import { apiUrl } from '../setup' + +// In-memory store for mock volumes +const volumes = new Map< + string, + { volumeID: string; name: string; token: string } +>() + +// In-memory store for mock volume file contents, keyed by path +const volumeFiles = new Map() + +const restHandlers = [ + // POST /volumes - create (returns VolumeAndToken) + http.post(apiUrl('/volumes'), async ({ request }) => { + const { name } = (await request.clone().json()) as { name: string } + const volumeID = randomUUID() + const token = `vol-token-${randomUUID()}` + volumes.set(volumeID, { volumeID, name, token }) + return HttpResponse.json({ volumeID, name, token }, { status: 201 }) + }), + + // GET /volumes - list (returns Volume[] without tokens) + http.get(apiUrl('/volumes'), () => { + const list = Array.from(volumes.values()).map(({ volumeID, name }) => ({ + volumeID, + name, + })) + return HttpResponse.json(list) + }), + + // GET /volumes/:volumeID - get info (returns VolumeAndToken with token) + http.get<{ volumeID: string }>(apiUrl('/volumes/:volumeID'), ({ params }) => { + const vol = volumes.get(params.volumeID) + if (!vol) { + return HttpResponse.json( + { code: 404, message: 'Not found' }, + { status: 404 } + ) + } + return HttpResponse.json(vol) + }), + + // DELETE /volumes/:volumeID - destroy + http.delete<{ volumeID: string }>( + apiUrl('/volumes/:volumeID'), + ({ params }) => { + const existed = volumes.delete(params.volumeID) + if (!existed) { + return HttpResponse.json( + { code: 404, message: 'Not found' }, + { status: 404 } + ) + } + return new HttpResponse(null, { status: 204 }) + } + ), + + // GET /volumecontent/:volumeID/file - read file content + http.get(apiUrl('/volumecontent/:volumeID/file'), ({ request }) => { + const path = new URL(request.url).searchParams.get('path') ?? '' + const content = volumeFiles.get(path) + if (content === undefined) { + return HttpResponse.json( + { code: 404, message: 'Not found' }, + { status: 404 } + ) + } + return new HttpResponse(content.length > 0 ? content : null, { + status: 200, + headers: { 'Content-Length': String(content.length) }, + }) + }), +] + +const server = setupServer(...restHandlers) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterAll(() => server.close()) +afterEach(() => { + server.resetHandlers() + volumes.clear() + volumeFiles.clear() +}) + +describe('Volume CRUD', () => { + it('should create a volume', async () => { + const vol = await Volume.create('test-volume') + + expect(vol).toBeDefined() + expect(vol.volumeId).toBeDefined() + expect(vol.name).toBe('test-volume') + expect(vol.token).toBeDefined() + expect(vol.token).not.toBe('undefined') + }) + + it('should get volume info', async () => { + const created = await Volume.create('info-volume') + const info = await Volume.getInfo(created.volumeId) + + expect(info.volumeId).toBe(created.volumeId) + expect(info.name).toBe('info-volume') + }) + + it('should list volumes', async () => { + await Volume.create('vol-a') + await Volume.create('vol-b') + + const list = await Volume.list() + + expect(list).toHaveLength(2) + expect(list.map((v) => v.name).sort()).toEqual(['vol-a', 'vol-b']) + }) + + it('should return empty list when no volumes exist', async () => { + const list = await Volume.list() + expect(list).toHaveLength(0) + }) + + it('should destroy a volume', async () => { + const vol = await Volume.create('to-delete') + const result = await Volume.destroy(vol.volumeId) + + expect(result).toBe(true) + + // Verify it's gone + const list = await Volume.list() + expect(list).toHaveLength(0) + }) + + it('should return false when destroying a non-existent volume', async () => { + const result = await Volume.destroy('non-existent-id') + expect(result).toBe(false) + }) + + it('should throw NotFoundError when getting info of non-existent volume', async () => { + await expect(Volume.getInfo('non-existent-id')).rejects.toThrow( + NotFoundError + ) + }) + + it('should throw VolumeError for a non-2xx response without content', async () => { + server.use( + http.post( + apiUrl('/volumes'), + () => + new HttpResponse(null, { + status: 500, + headers: { 'Content-Length': '0' }, + }) + ) + ) + + await expect(Volume.create('error-volume')).rejects.toThrow(VolumeError) + }) + + it('should keep the proxy on the instance so content calls reuse it', async () => { + const proxy = 'http://user:pass@127.0.0.1:8080' + const vol = await Volume.create('proxy-volume', { proxy }) + + // The proxy is stored on the instance... + expect(vol.proxy).toBe(proxy) + + // ...and instance methods (which build a VolumeConnectionConfig with no + // per-call proxy) pick it up rather than falling back to no proxy. + const config = new VolumeConnectionConfig(vol) + expect(config.proxy).toBe(proxy) + }) + + it('should let a per-call proxy override the instance proxy', async () => { + const vol = await Volume.create('proxy-volume', { + proxy: 'http://127.0.0.1:8080', + }) + + const config = new VolumeConnectionConfig(vol, { + proxy: 'http://127.0.0.1:9090', + }) + expect(config.proxy).toBe('http://127.0.0.1:9090') + }) + + it('should handle full lifecycle: create, get, list, destroy', async () => { + // Create + const vol = await Volume.create('lifecycle-vol') + expect(vol.name).toBe('lifecycle-vol') + + // Get info + const info = await Volume.getInfo(vol.volumeId) + expect(info.name).toBe('lifecycle-vol') + + // List - should contain the volume + const list = await Volume.list() + expect(list).toHaveLength(1) + expect(list[0].volumeId).toBe(vol.volumeId) + + // Destroy + const destroyed = await Volume.destroy(vol.volumeId) + expect(destroyed).toBe(true) + + // List again - should be empty + const listAfter = await Volume.list() + expect(listAfter).toHaveLength(0) + }) +}) + +describe('Volume content readFile', () => { + it('should return content for a non-empty file in every format', async () => { + volumeFiles.set('hello.txt', 'hello world') + const vol = await Volume.create('content-volume') + + const text = await vol.readFile('hello.txt') + expect(text).toBe('hello world') + + const bytes = await vol.readFile('hello.txt', { format: 'bytes' }) + expect(bytes).toBeInstanceOf(Uint8Array) + expect(new TextDecoder().decode(bytes)).toBe('hello world') + + const blob = await vol.readFile('hello.txt', { format: 'blob' }) + expect(blob).toBeInstanceOf(Blob) + expect(await blob.text()).toBe('hello world') + + const stream = await vol.readFile('hello.txt', { format: 'stream' }) + expect(stream).toBeInstanceOf(ReadableStream) + expect(await new Response(stream).text()).toBe('hello world') + }) + + it('should return empty values for an empty file in every format', async () => { + volumeFiles.set('empty.txt', '') + const vol = await Volume.create('content-volume') + + const text = await vol.readFile('empty.txt') + expect(text).toBe('') + + const bytes = await vol.readFile('empty.txt', { format: 'bytes' }) + expect(bytes).toBeInstanceOf(Uint8Array) + expect(bytes.length).toBe(0) + + const blob = await vol.readFile('empty.txt', { format: 'blob' }) + expect(blob).toBeInstanceOf(Blob) + expect(blob.size).toBe(0) + + const stream = await vol.readFile('empty.txt', { format: 'stream' }) + expect(stream).toBeInstanceOf(ReadableStream) + expect(await new Response(stream).text()).toBe('') + }) + + it('should throw NotFoundError for a missing file', async () => { + const vol = await Volume.create('content-volume') + + await expect(vol.readFile('missing.txt')).rejects.toThrow(NotFoundError) + }) + + it('should reject at call time for a missing file with stream format', async () => { + const vol = await Volume.create('content-volume') + + await expect( + vol.readFile('missing.txt', { format: 'stream' }) + ).rejects.toThrow(NotFoundError) + }) +}) + +describe('VolumeConnectionConfig request timeout', () => { + it('should default the request timeout to 60 seconds', async () => { + const vol = await Volume.create('timeout-volume') + + const config = new VolumeConnectionConfig(vol) + expect(config.requestTimeoutMs).toBe(60_000) + expect(config.getSignal()).toBeInstanceOf(AbortSignal) + }) + + it('should let a per-call timeout override the default', async () => { + const vol = await Volume.create('timeout-volume') + + const config = new VolumeConnectionConfig(vol, { requestTimeoutMs: 1_000 }) + expect(config.requestTimeoutMs).toBe(1_000) + }) + + it('should disable the timeout when requestTimeoutMs is 0', async () => { + const vol = await Volume.create('timeout-volume') + + const config = new VolumeConnectionConfig(vol, { requestTimeoutMs: 0 }) + expect(config.requestTimeoutMs).toBe(0) + expect(config.getSignal()).toBeUndefined() + }) +}) diff --git a/packages/js-sdk/tsconfig.json b/packages/js-sdk/tsconfig.json new file mode 100644 index 0000000..c922e31 --- /dev/null +++ b/packages/js-sdk/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "outDir": "dist", + "target": "es2022", + "module": "esnext", + // Keep assignment (not `[[Define]]`) semantics for class fields. `target: + // es2022` would default this to `true`, which changes class-field emit and + // shifts stack frames — breaking the template builder's fixed-depth caller + // resolution (getCallerDirectory / per-step stack traces). Adopting define + // semantics should be a separate, deliberately tested change. + "useDefineForClassFields": false, + "lib": [ + "dom", + "es2022" + ], + "sourceMap": true, + "skipLibCheck": true, + "esModuleInterop": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + }, + "include": [ + "src" + ], + "exclude": [ + "dist", + "node_modules", + "tsdown.config.ts" + ] +} diff --git a/packages/js-sdk/tsdown.config.ts b/packages/js-sdk/tsdown.config.ts new file mode 100644 index 0000000..115365e --- /dev/null +++ b/packages/js-sdk/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { index: './src/index.ts' }, + target: 'es2017', + format: ['esm', 'cjs'], + fixedExtension: false, + sourcemap: true, + dts: true, + clean: true, +}) diff --git a/packages/js-sdk/vitest.config.mts b/packages/js-sdk/vitest.config.mts new file mode 100644 index 0000000..39a5034 --- /dev/null +++ b/packages/js-sdk/vitest.config.mts @@ -0,0 +1,93 @@ +import { defineConfig } from 'vitest/config' +import { playwright } from '@vitest/browser-playwright' +import { config } from 'dotenv' + +const env = config() +export default defineConfig({ + test: { + projects: [ + { + test: { + name: 'unit', + include: ['tests/**/*.test.ts'], + exclude: [ + 'tests/runtimes/**', + 'tests/integration/**', + 'tests/template/**', + 'tests/connectionConfig.test.ts', + ], + // Isolation is required: several suites patch global fetch via msw + // and rely on module mocks (vi.doMock / vi.resetModules). Under + // vitest 4 a shared (non-isolated) context leaks this state across + // files — e.g. aborted-request rejections and the cached undici + // apiFetch singleton — causing cross-file failures. + isolate: true, + globals: false, + testTimeout: 30_000, + environment: 'node', + bail: 0, + server: {}, + deps: { + interopDefault: true, + }, + env: { + ...(process.env as Record), + ...env.parsed, + }, + }, + }, + { + test: { + name: 'browser', + include: ['tests/runtimes/browser/**/*.{test,spec}.tsx'], + browser: { + enabled: true, + headless: true, + instances: [{ browser: 'chromium' }], + provider: playwright(), + // https://playwright.dev + }, + provide: { + E2B_API_KEY: process.env.E2B_API_KEY || env.parsed?.E2B_API_KEY, + E2B_DOMAIN: process.env.E2B_DOMAIN || env.parsed?.E2B_DOMAIN, + }, + }, + }, + { + test: { + include: ['tests/runtimes/edge/**/*.{test,spec}.ts'], + name: 'edge', + environment: 'edge-runtime', + }, + }, + { + test: { + name: 'integration', + include: ['tests/integration/**/*.test.ts'], + globals: false, + testTimeout: 60_000, + environment: 'node', + }, + }, + { + test: { + name: 'template', + include: ['tests/template/**/*.test.ts'], + globals: false, + testTimeout: 180_000, + environment: 'node', + }, + }, + { + test: { + name: 'connectionConfig', + include: ['tests/connectionConfig.test.ts'], + globals: false, + isolate: true, + testTimeout: 10_000, + environment: 'node', + }, + }, + ], + }, +}) diff --git a/packages/python-sdk/.gitignore b/packages/python-sdk/.gitignore new file mode 100644 index 0000000..5874277 --- /dev/null +++ b/packages/python-sdk/.gitignore @@ -0,0 +1,268 @@ +tests/assets/video-downloaded.webm + +### Node template +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pdm +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +.idea/ + +# Test results +results.csv diff --git a/packages/python-sdk/LICENSE b/packages/python-sdk/LICENSE new file mode 100644 index 0000000..4aa6529 --- /dev/null +++ b/packages/python-sdk/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2025 FOUNDRYLABS, INC. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/python-sdk/Makefile b/packages/python-sdk/Makefile new file mode 100644 index 0000000..3fc3e72 --- /dev/null +++ b/packages/python-sdk/Makefile @@ -0,0 +1,59 @@ +ROOT_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))/../..) + +generate-api: + python $(ROOT_DIR)/spec/remove_extra_tags.py sandboxes snapshots templates tags volumes + openapi-python-client generate --output-path $(ROOT_DIR)/packages/python-sdk/e2b/api/api --overwrite --path $(ROOT_DIR)/spec/openapi_generated.yml + rm -rf e2b/api/client + mv e2b/api/api/e2b_api_client e2b/api/client + rm -rf e2b/api/api + ruff format . + +generate-volume-api: + openapi-python-client generate --output-path $(ROOT_DIR)/packages/python-sdk/e2b/volume/api --overwrite --path $(ROOT_DIR)/spec/openapi-volumecontent.yml + rm -rf e2b/volume/client + mv e2b/volume/api/e2b_api_client e2b/volume/client + rm -rf e2b/volume/api + ruff format . + +generate-envd: + if [ ! -f "/go/bin/protoc-gen-connect-python" ]; then \ + $(MAKE) -C $(ROOT_DIR)/packages/connect-python build; \ + fi + + cd $(ROOT_DIR)/spec/envd && pwd && buf generate --template buf-python.gen.yaml + ./scripts/fix-python-pb.sh + + ruff format . + +generate: generate-api generate-envd generate-mcp generate-volume-api + +# Install the code-generation toolchain into the uv env. After this, run the +# generators locally with e.g. `uv run make generate-api`. +init: + uv sync --group codegen + +typecheck: + ty check + +lint: + ruff check . + ruff format --check . + +format: + ruff format . + +generate-mcp: + datamodel-codegen \ + --input ../../spec/mcp-server.json \ + --input-file-type jsonschema \ + --output e2b/sandbox/mcp.py \ + --output-model-type typing.TypedDict \ + --target-python-version 3.10 \ + --class-name McpServer \ + --use-field-description \ + --disable-timestamp \ + --extra-fields forbid + +.PHONY: test +test: + uv run pytest --verbose --numprocesses=4 diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md new file mode 100644 index 0000000..fa39401 --- /dev/null +++ b/packages/python-sdk/README.md @@ -0,0 +1,65 @@ +

+ + + + E2B Logo + +

+ +

+ + Last 1 month downloads for the Python SDK + +

+ + +## What is E2B? +[E2B](https://www.e2b.dev/) is an open-source infrastructure that allows you to run AI-generated code in secure isolated sandboxes in the cloud. To start and control sandboxes, use our [JavaScript SDK](https://www.npmjs.com/package/e2b) or [Python SDK](https://pypi.org/project/e2b). + +## Run your first Sandbox + +### 1. Install SDK + +``` +pip install e2b +``` + +### 2. Get your E2B API key +1. Sign up to E2B [here](https://e2b.dev). +2. Get your API key [here](https://e2b.dev/dashboard?tab=keys). +3. Set environment variable with your API key +``` +E2B_API_KEY=e2b_*** +``` + +### 3. Start a sandbox and run commands + +```py +from e2b import Sandbox + +with Sandbox.create() as sandbox: + result = sandbox.commands.run('echo "Hello from E2B!"') + print(result.stdout) # Hello from E2B! +``` + +### 4. Code execution with Code Interpreter + +If you need [`run_code()`](https://e2b.dev/docs/code-interpreting), install the [Code Interpreter SDK](https://github.com/e2b-dev/code-interpreter): + +``` +pip install e2b-code-interpreter +``` + +```py +from e2b_code_interpreter import Sandbox + +with Sandbox.create() as sandbox: + execution = sandbox.run_code("x = 1; x += 1; x") + print(execution.text) # outputs 2 +``` + +### 5. Check docs +Visit [E2B documentation](https://e2b.dev/docs). + +### 6. E2B cookbook +Visit our [Cookbook](https://github.com/e2b-dev/e2b-cookbook/tree/main) to get inspired by examples with different LLMs and AI frameworks. diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py new file mode 100644 index 0000000..c8b65aa --- /dev/null +++ b/packages/python-sdk/e2b/__init__.py @@ -0,0 +1,260 @@ +""" +Secure sandboxed cloud environments made for AI agents and AI apps. + +Check docs [here](https://e2b.dev/docs). + +E2B Sandbox is a secure cloud sandbox environment made for AI agents and AI +apps. +Sandboxes allow AI agents and apps to have long running cloud secure environments. +In these environments, large language models can use the same tools as humans do. + +E2B Python SDK supports both sync and async API: + +```py +from e2b import Sandbox + +# Create sandbox +sandbox = Sandbox.create() +``` + +```py +from e2b import AsyncSandbox + +# Create sandbox +sandbox = await AsyncSandbox.create() +``` +""" + +from .api import ( + ApiClient, + client, +) +from .connection_config import ( + ApiParams, + ConnectionConfig, + ProxyTypes, + Username, +) +from .volume.connection_config import VolumeApiParams, VolumeConnectionConfig +from .exceptions import ( + AuthenticationException, + FileNotFoundException, + GitAuthException, + GitUpstreamException, + BuildException, + FileUploadException, + InvalidArgumentException, + NotEnoughSpaceException, + NotFoundException, + RateLimitException, + SandboxException, + SandboxNotFoundException, + TemplateException, + TimeoutException, + VolumeException, +) +from .sandbox.commands.command_handle import ( + CommandExitException, + CommandResult, + PtyOutput, + PtySize, + Stderr, + Stdout, +) +from .sandbox.commands.main import ProcessInfo +from .sandbox.filesystem.filesystem import EntryInfo, FileType, WriteInfo +from .sandbox.filesystem.watch_handle import ( + FilesystemEvent, + FilesystemEventType, +) +from .sandbox._git import GitBranches, GitFileStatus, GitResetMode, GitStatus +from .sandbox_sync.git import Git +from .sandbox.network import ALL_TRAFFIC +from .sandbox.signature import get_signature +from .sandbox.sandbox_api import ( + GitHubMcpServer, + GitHubMcpServerConfig, + McpServer, + SandboxInfo, + SandboxInfoLifecycle, + SandboxMetrics, + SandboxLifecycle, + SandboxOnTimeout, + SandboxNetworkInfo, + SandboxNetworkOpts, + SandboxNetworkRule, + SandboxNetworkRuleInfo, + SandboxNetworkRules, + SandboxNetworkSelector, + SandboxNetworkSelectorContext, + SandboxNetworkTransform, + SandboxNetworkUpdate, + SandboxQuery, + SandboxState, + SnapshotInfo, +) +from .sandbox_async.commands.command_handle import AsyncCommandHandle +from .sandbox_async.filesystem.watch_handle import AsyncWatchHandle +from .sandbox_async.main import AsyncSandbox +from .sandbox_async.paginator import AsyncSandboxPaginator, AsyncSnapshotPaginator +from .sandbox_async.utils import OutputHandler +from .sandbox_sync.commands.command_handle import CommandHandle +from .sandbox_sync.filesystem.watch_handle import WatchHandle +from .sandbox_sync.main import Sandbox +from .sandbox_sync.paginator import SandboxPaginator, SnapshotPaginator +from .template.logger import ( + LogEntry, + LogEntryEnd, + LogEntryLevel, + LogEntryStart, + default_build_logger, +) +from .template.main import TemplateBase, TemplateClass +from .template.readycmd import ( + ReadyCmd, + wait_for_file, + wait_for_port, + wait_for_process, + wait_for_timeout, + wait_for_url, +) +from .template.types import ( + BuildInfo, + BuildStatusReason, + CopyItem, + TemplateBuildStatus, + TemplateBuildStatusResponse, + TemplateTag, + TemplateTagInfo, +) +from .template_async.main import AsyncTemplate +from .template_sync.main import Template + +from .volume.volume_sync import Volume +from .volume.volume_async import AsyncVolume +from .volume.types import ( + VolumeInfo, + VolumeAndToken, + VolumeEntryStat, + VolumeFileType, +) + +__all__ = [ + # API + "ApiClient", + "client", + # Connection config + "ConnectionConfig", + "VolumeConnectionConfig", + "ProxyTypes", + "ApiParams", + "VolumeApiParams", + "Username", + # Exceptions + "SandboxException", + "TimeoutException", + "NotFoundException", + "FileNotFoundException", + "SandboxNotFoundException", + "AuthenticationException", + "GitAuthException", + "GitUpstreamException", + "InvalidArgumentException", + "NotEnoughSpaceException", + "TemplateException", + "BuildException", + "FileUploadException", + "RateLimitException", + "VolumeException", + # Sandbox API + "SandboxInfo", + "SandboxInfoLifecycle", + "SandboxMetrics", + "ProcessInfo", + "SandboxQuery", + "SandboxState", + "SandboxMetrics", + "GitStatus", + "GitBranches", + "GitFileStatus", + "GitResetMode", + # Command handle + "CommandResult", + "Stderr", + "Stdout", + "CommandExitException", + "PtyOutput", + "PtySize", + # Filesystem + "FilesystemEvent", + "FilesystemEventType", + "EntryInfo", + "WriteInfo", + "FileType", + # Network + "SandboxNetworkOpts", + "SandboxNetworkInfo", + "SandboxNetworkSelector", + "SandboxNetworkSelectorContext", + "SandboxNetworkRule", + "SandboxNetworkRuleInfo", + "SandboxNetworkRules", + "SandboxNetworkTransform", + "SandboxNetworkUpdate", + "SandboxLifecycle", + "SandboxOnTimeout", + "ALL_TRAFFIC", + # Snapshot + "SnapshotInfo", + "SnapshotPaginator", + "AsyncSnapshotPaginator", + # Signature + "get_signature", + # Sync sandbox + "Sandbox", + "SandboxPaginator", + "WatchHandle", + "CommandHandle", + # Async sandbox + "OutputHandler", + "AsyncSandboxPaginator", + "AsyncSandbox", + "AsyncWatchHandle", + "AsyncCommandHandle", + # Template + "Template", + "AsyncTemplate", + "TemplateBase", + "TemplateClass", + "CopyItem", + "BuildInfo", + "BuildStatusReason", + "TemplateBuildStatus", + "TemplateBuildStatusResponse", + "TemplateTag", + "TemplateTagInfo", + "ReadyCmd", + "wait_for_file", + "wait_for_url", + "wait_for_port", + "wait_for_process", + "wait_for_timeout", + "LogEntry", + "LogEntryStart", + "LogEntryEnd", + "LogEntryLevel", + "default_build_logger", + # MCP + "McpServer", + "GitHubMcpServer", + "GitHubMcpServerConfig", + # Git + "Git", + # Volume + "Volume", + "AsyncVolume", + "VolumeInfo", + "VolumeAndToken", + "VolumeEntryStat", + "VolumeFileType", +] diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py new file mode 100644 index 0000000..2a1e42d --- /dev/null +++ b/packages/python-sdk/e2b/api/__init__.py @@ -0,0 +1,287 @@ +import asyncio +import json +import logging +import os +import re +import threading +import weakref +from dataclasses import dataclass +from types import TracebackType +from typing import Callable, Optional, Protocol, Union + +import httpx +from httpx import AsyncBaseTransport, BaseTransport, Limits, Timeout + +from e2b.api.client.client import AuthenticatedClient +from e2b.api.client.types import Response +from e2b.api.metadata import default_headers +from e2b.connection_config import ConnectionConfig +from e2b.exceptions import ( + AuthenticationException, + RateLimitException, + SandboxException, +) + + +def make_logging_event_hooks(log: Optional[logging.Logger]) -> dict: + """Build synchronous httpx ``event_hooks`` that log requests and responses + to the given ``logging.Logger``. Requests log at ``INFO``, successful + responses at ``INFO`` and responses with status >= 400 at ``ERROR``. + + Returns no hooks when ``log`` is ``None`` so that nothing is logged unless a + logger was explicitly supplied.""" + if log is None: + return {} + + def on_request(request) -> None: + log.info(f"Request {request.method} {request.url}") + + def on_response(response: Response) -> None: + if response.status_code >= 400: + log.error(f"Response {response.status_code}") + else: + log.info(f"Response {response.status_code}") + + return {"request": [on_request], "response": [on_response]} + + +def make_async_logging_event_hooks(log: Optional[logging.Logger]) -> dict: + """Asynchronous counterpart of :func:`make_logging_event_hooks`.""" + if log is None: + return {} + + async def on_request(request) -> None: + log.info(f"Request {request.method} {request.url}") + + async def on_response(response: Response) -> None: + if response.status_code >= 400: + log.error(f"Response {response.status_code}") + else: + log.info(f"Response {response.status_code}") + + return {"request": [on_request], "response": [on_response]} + + +limits = Limits( + max_keepalive_connections=int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS", "20")), + max_connections=int(os.getenv("E2B_MAX_CONNECTIONS", "2000")), + keepalive_expiry=int(os.getenv("E2B_KEEPALIVE_EXPIRY", "300")), +) + +connection_retries = int(os.getenv("E2B_CONNECTION_RETRIES", "3")) + + +@dataclass +class SandboxCreateResponse: + sandbox_id: str + sandbox_domain: Optional[str] + envd_version: str + envd_access_token: Optional[str] + traffic_access_token: Optional[str] + + +def handle_api_exception( + e: "SupportsApiErrorResponse", + default_exception_class: type[Exception] = SandboxException, + stack_trace: Optional[TracebackType] = None, +): + try: + body = json.loads(e.content) if e.content else {} + except json.JSONDecodeError: + body = {} + + if e.status_code == 401: + message = f"{e.status_code}: Unauthorized, please check your credentials." + if body.get("message"): + message += f" - {body['message']}" + return AuthenticationException(message) + + if e.status_code == 429: + message = f"{e.status_code}: Rate limit exceeded, please try again later." + if body.get("message"): + message += f" - {body['message']}" + return RateLimitException(message) + + if "message" in body: + return default_exception_class( + f"{e.status_code}: {body['message']}" + ).with_traceback(stack_trace) + return default_exception_class(f"{e.status_code}: {e.content}").with_traceback( + stack_trace + ) + + +class SupportsApiErrorResponse(Protocol): + @property + def status_code(self) -> int: ... + + @property + def content(self) -> Union[str, bytes]: ... + + +_API_KEY_PATTERN = re.compile(r"\Ae2b_[0-9a-f]+\Z") +_API_KEY_EXAMPLE = "e2b_" + "0" * 40 + + +def validate_api_key(api_key: str) -> None: + """Validate that an E2B API key has the expected ``e2b_`` prefix + followed by hex characters. Raises ``AuthenticationException`` otherwise. + """ + if not _API_KEY_PATTERN.match(api_key): + raise AuthenticationException( + 'Invalid API key format: expected "e2b_" followed by hex ' + f'characters (e.g. "{_API_KEY_EXAMPLE}"). ' + "Visit the API Keys tab at https://e2b.dev/dashboard?tab=keys to get your API key." + ) + + +class ApiClient(AuthenticatedClient): + """ + The client for interacting with the E2B API. + """ + + def __init__( + self, + config: ConnectionConfig, + transport: Optional[Union[BaseTransport, AsyncBaseTransport]] = None, + transport_factory: Optional[Callable[[], BaseTransport]] = None, + async_transport_factory: Optional[Callable[[], AsyncBaseTransport]] = None, + *args, + **kwargs, + ): + if transport is not None and ( + transport_factory is not None or async_transport_factory is not None + ): + raise ValueError("Use either transport or transport_factory, not both") + + self._transport_factory = transport_factory + self._async_transport_factory = async_transport_factory + self._thread_local = threading.local() + # Keyed weakly by the event loop object itself, not id(loop) — + # CPython reuses object ids, so a new loop could otherwise inherit + # a client bound to a previous, closed loop. + self._async_clients: weakref.WeakKeyDictionary[ + asyncio.AbstractEventLoop, httpx.AsyncClient + ] = weakref.WeakKeyDictionary() + self._proxy = config.proxy + + if config.api_key is None: + raise AuthenticationException( + "API key is required, please visit the API Keys tab at https://e2b.dev/dashboard?tab=keys to get your API key. " + "You can either set the environment variable `E2B_API_KEY` " + 'or you can pass it directly to the method like api_key="e2b_..."', + ) + + if config.api_key is not None and config.validate_api_key: + validate_api_key(config.api_key) + + token = config.api_key + auth_header_name = "X-API-KEY" + prefix = "" + + self._logger = config.logger + + headers = { + **default_headers, + # Deprecated: send the access token alongside the API key when one + # is available, mirroring the JS SDK. Prefer `api_headers` instead. + # Spread before `config.headers` so a custom `Authorization` in + # `api_headers` wins over the deprecated access token, matching JS. + **( + {"Authorization": f"Bearer {config.access_token}"} + if config.access_token is not None + else {} + ), + **(config.headers or {}), + } + + # Prevent passing these parameters twice + more_headers: Optional[dict] = kwargs.pop("headers", None) + if more_headers: + headers.update(more_headers) + kwargs.pop("token", None) + kwargs.pop("auth_header_name", None) + kwargs.pop("prefix", None) + + httpx_args = { + "event_hooks": self._logging_event_hooks(), + } + if transport is not None: + httpx_args["transport"] = transport + if ( + transport is None + and transport_factory is None + and async_transport_factory is None + ): + httpx_args["proxy"] = config.proxy + + # config.request_timeout is None when the timeout is explicitly + # disabled (request_timeout=0), which httpx.Timeout(None) preserves. + kwargs.setdefault("timeout", Timeout(config.request_timeout)) + + super().__init__( + base_url=config.api_url, + httpx_args=httpx_args, + headers=headers, + token=token or "", + auth_header_name=auth_header_name, + prefix=prefix, + *args, + **kwargs, + ) + + def _logging_event_hooks(self) -> dict: + return make_logging_event_hooks(self._logger) + + def _headers_with_auth(self) -> dict: + return { + **self._headers, + self.auth_header_name: ( + f"{self.prefix} {self.token}" if self.prefix else self.token + ), + } + + def get_httpx_client(self) -> httpx.Client: + if self._client is not None or self._transport_factory is None: + return super().get_httpx_client() + + client = getattr(self._thread_local, "client", None) + if client is None: + client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers_with_auth(), + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + event_hooks=self._httpx_args.get("event_hooks"), + transport=self._transport_factory(), + ) + self._thread_local.client = client + return client + + def get_async_httpx_client(self) -> httpx.AsyncClient: + if self._async_client is not None or self._async_transport_factory is None: + return super().get_async_httpx_client() + + loop = asyncio.get_running_loop() + client = self._async_clients.get(loop) + if client is None: + client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers_with_auth(), + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + event_hooks=self._httpx_args.get("event_hooks"), + transport=self._async_transport_factory(), + ) + self._async_clients[loop] = client + return client + + +# We need to override the logging hooks for the async usage +class AsyncApiClient(ApiClient): + def _logging_event_hooks(self) -> dict: + return make_async_logging_event_hooks(self._logger) diff --git a/packages/python-sdk/e2b/api/client/__init__.py b/packages/python-sdk/e2b/api/client/__init__.py new file mode 100644 index 0000000..10d90b0 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/__init__.py @@ -0,0 +1,8 @@ +"""A client library for accessing E2B API""" + +from .client import AuthenticatedClient, Client + +__all__ = ( + "AuthenticatedClient", + "Client", +) diff --git a/packages/python-sdk/e2b/api/client/api/__init__.py b/packages/python-sdk/e2b/api/client/api/__init__.py new file mode 100644 index 0000000..81f9fa2 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/__init__.py @@ -0,0 +1 @@ +"""Contains methods for accessing the API""" diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/__init__.py b/packages/python-sdk/e2b/api/client/api/sandboxes/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/delete_sandboxes_sandbox_id.py b/packages/python-sdk/e2b/api/client/api/sandboxes/delete_sandboxes_sandbox_id.py new file mode 100644 index 0000000..77288c4 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/delete_sandboxes_sandbox_id.py @@ -0,0 +1,161 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "delete", + "url": f"/sandboxes/{sandbox_id}", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Kill a sandbox + + Args: + sandbox_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, Error]]: + """Kill a sandbox + + Args: + sandbox_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Kill a sandbox + + Args: + sandbox_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, Error]]: + """Kill a sandbox + + Args: + sandbox_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes.py b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes.py new file mode 100644 index 0000000..19829e5 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes.py @@ -0,0 +1,176 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.listed_sandbox import ListedSandbox +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + metadata: Union[Unset, str] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["metadata"] = metadata + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/sandboxes", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, list["ListedSandbox"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = ListedSandbox.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, list["ListedSandbox"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, +) -> Response[Union[Error, list["ListedSandbox"]]]: + """List all running sandboxes + + Args: + metadata (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['ListedSandbox']]] + """ + + kwargs = _get_kwargs( + metadata=metadata, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, +) -> Optional[Union[Error, list["ListedSandbox"]]]: + """List all running sandboxes + + Args: + metadata (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['ListedSandbox']] + """ + + return sync_detailed( + client=client, + metadata=metadata, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, +) -> Response[Union[Error, list["ListedSandbox"]]]: + """List all running sandboxes + + Args: + metadata (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['ListedSandbox']]] + """ + + kwargs = _get_kwargs( + metadata=metadata, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, +) -> Optional[Union[Error, list["ListedSandbox"]]]: + """List all running sandboxes + + Args: + metadata (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['ListedSandbox']] + """ + + return ( + await asyncio_detailed( + client=client, + metadata=metadata, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_metrics.py b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_metrics.py new file mode 100644 index 0000000..d05b6f9 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_metrics.py @@ -0,0 +1,173 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.sandboxes_with_metrics import SandboxesWithMetrics +from ...types import UNSET, Response + + +def _get_kwargs( + *, + sandbox_ids: list[str], +) -> dict[str, Any]: + params: dict[str, Any] = {} + + json_sandbox_ids = sandbox_ids + + params["sandbox_ids"] = ",".join(str(item) for item in json_sandbox_ids) + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/sandboxes/metrics", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, SandboxesWithMetrics]]: + if response.status_code == 200: + response_200 = SandboxesWithMetrics.from_dict(response.json()) + + return response_200 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, SandboxesWithMetrics]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + sandbox_ids: list[str], +) -> Response[Union[Error, SandboxesWithMetrics]]: + """List metrics for given sandboxes + + Args: + sandbox_ids (list[str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, SandboxesWithMetrics]] + """ + + kwargs = _get_kwargs( + sandbox_ids=sandbox_ids, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + sandbox_ids: list[str], +) -> Optional[Union[Error, SandboxesWithMetrics]]: + """List metrics for given sandboxes + + Args: + sandbox_ids (list[str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, SandboxesWithMetrics] + """ + + return sync_detailed( + client=client, + sandbox_ids=sandbox_ids, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + sandbox_ids: list[str], +) -> Response[Union[Error, SandboxesWithMetrics]]: + """List metrics for given sandboxes + + Args: + sandbox_ids (list[str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, SandboxesWithMetrics]] + """ + + kwargs = _get_kwargs( + sandbox_ids=sandbox_ids, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + sandbox_ids: list[str], +) -> Optional[Union[Error, SandboxesWithMetrics]]: + """List metrics for given sandboxes + + Args: + sandbox_ids (list[str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, SandboxesWithMetrics] + """ + + return ( + await asyncio_detailed( + client=client, + sandbox_ids=sandbox_ids, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id.py b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id.py new file mode 100644 index 0000000..74f0b27 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id.py @@ -0,0 +1,163 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.sandbox_detail import SandboxDetail +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/sandboxes/{sandbox_id}", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, SandboxDetail]]: + if response.status_code == 200: + response_200 = SandboxDetail.from_dict(response.json()) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, SandboxDetail]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, SandboxDetail]]: + """Get a sandbox by id + + Args: + sandbox_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, SandboxDetail]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, SandboxDetail]]: + """Get a sandbox by id + + Args: + sandbox_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, SandboxDetail] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, SandboxDetail]]: + """Get a sandbox by id + + Args: + sandbox_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, SandboxDetail]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, SandboxDetail]]: + """Get a sandbox by id + + Args: + sandbox_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, SandboxDetail] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id_logs.py b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id_logs.py new file mode 100644 index 0000000..39d7460 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id_logs.py @@ -0,0 +1,199 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.sandbox_logs import SandboxLogs +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + sandbox_id: str, + *, + start: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["start"] = start + + params["limit"] = limit + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/sandboxes/{sandbox_id}/logs", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, SandboxLogs]]: + if response.status_code == 200: + response_200 = SandboxLogs.from_dict(response.json()) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, SandboxLogs]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + start: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, +) -> Response[Union[Error, SandboxLogs]]: + """Get sandbox logs. Use /v2/sandboxes/{sandboxID}/logs instead. + + Args: + sandbox_id (str): + start (Union[Unset, int]): + limit (Union[Unset, int]): Default: 1000. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, SandboxLogs]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + start=start, + limit=limit, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + start: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, +) -> Optional[Union[Error, SandboxLogs]]: + """Get sandbox logs. Use /v2/sandboxes/{sandboxID}/logs instead. + + Args: + sandbox_id (str): + start (Union[Unset, int]): + limit (Union[Unset, int]): Default: 1000. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, SandboxLogs] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + start=start, + limit=limit, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + start: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, +) -> Response[Union[Error, SandboxLogs]]: + """Get sandbox logs. Use /v2/sandboxes/{sandboxID}/logs instead. + + Args: + sandbox_id (str): + start (Union[Unset, int]): + limit (Union[Unset, int]): Default: 1000. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, SandboxLogs]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + start=start, + limit=limit, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + start: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, +) -> Optional[Union[Error, SandboxLogs]]: + """Get sandbox logs. Use /v2/sandboxes/{sandboxID}/logs instead. + + Args: + sandbox_id (str): + start (Union[Unset, int]): + limit (Union[Unset, int]): Default: 1000. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, SandboxLogs] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + start=start, + limit=limit, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id_metrics.py b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id_metrics.py new file mode 100644 index 0000000..48302c5 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id_metrics.py @@ -0,0 +1,212 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.sandbox_metric import SandboxMetric +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + sandbox_id: str, + *, + start: Union[Unset, int] = UNSET, + end: Union[Unset, int] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["start"] = start + + params["end"] = end + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/sandboxes/{sandbox_id}/metrics", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, list["SandboxMetric"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = SandboxMetric.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, list["SandboxMetric"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + start: Union[Unset, int] = UNSET, + end: Union[Unset, int] = UNSET, +) -> Response[Union[Error, list["SandboxMetric"]]]: + """Get sandbox metrics + + Args: + sandbox_id (str): + start (Union[Unset, int]): + end (Union[Unset, int]): Unix timestamp for the end of the interval, in seconds, for which + the metrics + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['SandboxMetric']]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + start=start, + end=end, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + start: Union[Unset, int] = UNSET, + end: Union[Unset, int] = UNSET, +) -> Optional[Union[Error, list["SandboxMetric"]]]: + """Get sandbox metrics + + Args: + sandbox_id (str): + start (Union[Unset, int]): + end (Union[Unset, int]): Unix timestamp for the end of the interval, in seconds, for which + the metrics + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['SandboxMetric']] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + start=start, + end=end, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + start: Union[Unset, int] = UNSET, + end: Union[Unset, int] = UNSET, +) -> Response[Union[Error, list["SandboxMetric"]]]: + """Get sandbox metrics + + Args: + sandbox_id (str): + start (Union[Unset, int]): + end (Union[Unset, int]): Unix timestamp for the end of the interval, in seconds, for which + the metrics + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['SandboxMetric']]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + start=start, + end=end, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + start: Union[Unset, int] = UNSET, + end: Union[Unset, int] = UNSET, +) -> Optional[Union[Error, list["SandboxMetric"]]]: + """Get sandbox metrics + + Args: + sandbox_id (str): + start (Union[Unset, int]): + end (Union[Unset, int]): Unix timestamp for the end of the interval, in seconds, for which + the metrics + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['SandboxMetric']] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + start=start, + end=end, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/get_v2_sandboxes.py b/packages/python-sdk/e2b/api/client/api/sandboxes/get_v2_sandboxes.py new file mode 100644 index 0000000..4955f81 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/get_v2_sandboxes.py @@ -0,0 +1,230 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.listed_sandbox import ListedSandbox +from ...models.sandbox_state import SandboxState +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + metadata: Union[Unset, str] = UNSET, + state: Union[Unset, list[SandboxState]] = UNSET, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["metadata"] = metadata + + json_state: Union[Unset, list[str]] = UNSET + if not isinstance(state, Unset): + json_state = [] + for state_item_data in state: + state_item = state_item_data.value + json_state.append(state_item) + + if not isinstance(json_state, Unset): + params["state"] = ",".join(str(item) for item in json_state) + + params["nextToken"] = next_token + + params["limit"] = limit + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/v2/sandboxes", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, list["ListedSandbox"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = ListedSandbox.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, list["ListedSandbox"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, + state: Union[Unset, list[SandboxState]] = UNSET, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> Response[Union[Error, list["ListedSandbox"]]]: + """List all sandboxes + + Args: + metadata (Union[Unset, str]): + state (Union[Unset, list[SandboxState]]): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['ListedSandbox']]] + """ + + kwargs = _get_kwargs( + metadata=metadata, + state=state, + next_token=next_token, + limit=limit, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, + state: Union[Unset, list[SandboxState]] = UNSET, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> Optional[Union[Error, list["ListedSandbox"]]]: + """List all sandboxes + + Args: + metadata (Union[Unset, str]): + state (Union[Unset, list[SandboxState]]): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['ListedSandbox']] + """ + + return sync_detailed( + client=client, + metadata=metadata, + state=state, + next_token=next_token, + limit=limit, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, + state: Union[Unset, list[SandboxState]] = UNSET, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> Response[Union[Error, list["ListedSandbox"]]]: + """List all sandboxes + + Args: + metadata (Union[Unset, str]): + state (Union[Unset, list[SandboxState]]): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['ListedSandbox']]] + """ + + kwargs = _get_kwargs( + metadata=metadata, + state=state, + next_token=next_token, + limit=limit, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, + state: Union[Unset, list[SandboxState]] = UNSET, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> Optional[Union[Error, list["ListedSandbox"]]]: + """List all sandboxes + + Args: + metadata (Union[Unset, str]): + state (Union[Unset, list[SandboxState]]): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['ListedSandbox']] + """ + + return ( + await asyncio_detailed( + client=client, + metadata=metadata, + state=state, + next_token=next_token, + limit=limit, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/get_v_2_sandboxes_sandbox_id_logs.py b/packages/python-sdk/e2b/api/client/api/sandboxes/get_v_2_sandboxes_sandbox_id_logs.py new file mode 100644 index 0000000..6a19076 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/get_v_2_sandboxes_sandbox_id_logs.py @@ -0,0 +1,254 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.log_level import LogLevel +from ...models.logs_direction import LogsDirection +from ...models.sandbox_logs_v2_response import SandboxLogsV2Response +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + sandbox_id: str, + *, + cursor: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, + direction: Union[Unset, LogsDirection] = UNSET, + level: Union[Unset, LogLevel] = UNSET, + search: Union[Unset, str] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["cursor"] = cursor + + params["limit"] = limit + + json_direction: Union[Unset, str] = UNSET + if not isinstance(direction, Unset): + json_direction = direction.value + + params["direction"] = json_direction + + json_level: Union[Unset, str] = UNSET + if not isinstance(level, Unset): + json_level = level.value + + params["level"] = json_level + + params["search"] = search + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/v2/sandboxes/{sandbox_id}/logs", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, SandboxLogsV2Response]]: + if response.status_code == 200: + response_200 = SandboxLogsV2Response.from_dict(response.json()) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, SandboxLogsV2Response]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + cursor: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, + direction: Union[Unset, LogsDirection] = UNSET, + level: Union[Unset, LogLevel] = UNSET, + search: Union[Unset, str] = UNSET, +) -> Response[Union[Error, SandboxLogsV2Response]]: + """Get sandbox logs + + Args: + sandbox_id (str): + cursor (Union[Unset, int]): + limit (Union[Unset, int]): Default: 1000. + direction (Union[Unset, LogsDirection]): Direction of the logs that should be returned + level (Union[Unset, LogLevel]): State of the sandbox + search (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, SandboxLogsV2Response]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + cursor=cursor, + limit=limit, + direction=direction, + level=level, + search=search, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + cursor: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, + direction: Union[Unset, LogsDirection] = UNSET, + level: Union[Unset, LogLevel] = UNSET, + search: Union[Unset, str] = UNSET, +) -> Optional[Union[Error, SandboxLogsV2Response]]: + """Get sandbox logs + + Args: + sandbox_id (str): + cursor (Union[Unset, int]): + limit (Union[Unset, int]): Default: 1000. + direction (Union[Unset, LogsDirection]): Direction of the logs that should be returned + level (Union[Unset, LogLevel]): State of the sandbox + search (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, SandboxLogsV2Response] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + cursor=cursor, + limit=limit, + direction=direction, + level=level, + search=search, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + cursor: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, + direction: Union[Unset, LogsDirection] = UNSET, + level: Union[Unset, LogLevel] = UNSET, + search: Union[Unset, str] = UNSET, +) -> Response[Union[Error, SandboxLogsV2Response]]: + """Get sandbox logs + + Args: + sandbox_id (str): + cursor (Union[Unset, int]): + limit (Union[Unset, int]): Default: 1000. + direction (Union[Unset, LogsDirection]): Direction of the logs that should be returned + level (Union[Unset, LogLevel]): State of the sandbox + search (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, SandboxLogsV2Response]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + cursor=cursor, + limit=limit, + direction=direction, + level=level, + search=search, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + cursor: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, + direction: Union[Unset, LogsDirection] = UNSET, + level: Union[Unset, LogLevel] = UNSET, + search: Union[Unset, str] = UNSET, +) -> Optional[Union[Error, SandboxLogsV2Response]]: + """Get sandbox logs + + Args: + sandbox_id (str): + cursor (Union[Unset, int]): + limit (Union[Unset, int]): Default: 1000. + direction (Union[Unset, LogsDirection]): Direction of the logs that should be returned + level (Union[Unset, LogLevel]): State of the sandbox + search (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, SandboxLogsV2Response] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + cursor=cursor, + limit=limit, + direction=direction, + level=level, + search=search, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes.py b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes.py new file mode 100644 index 0000000..36d79cb --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes.py @@ -0,0 +1,172 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.new_sandbox import NewSandbox +from ...models.sandbox import Sandbox +from ...types import Response + + +def _get_kwargs( + *, + body: NewSandbox, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/sandboxes", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, Sandbox]]: + if response.status_code == 201: + response_201 = Sandbox.from_dict(response.json()) + + return response_201 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, Sandbox]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: NewSandbox, +) -> Response[Union[Error, Sandbox]]: + """Create a sandbox from the template + + Args: + body (NewSandbox): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, Sandbox]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: NewSandbox, +) -> Optional[Union[Error, Sandbox]]: + """Create a sandbox from the template + + Args: + body (NewSandbox): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, Sandbox] + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: NewSandbox, +) -> Response[Union[Error, Sandbox]]: + """Create a sandbox from the template + + Args: + body (NewSandbox): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, Sandbox]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: NewSandbox, +) -> Optional[Union[Error, Sandbox]]: + """Create a sandbox from the template + + Args: + body (NewSandbox): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, Sandbox] + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_connect.py b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_connect.py new file mode 100644 index 0000000..c3b71fc --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_connect.py @@ -0,0 +1,193 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.connect_sandbox import ConnectSandbox +from ...models.error import Error +from ...models.sandbox import Sandbox +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, + *, + body: ConnectSandbox, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/sandboxes/{sandbox_id}/connect", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, Sandbox]]: + if response.status_code == 200: + response_200 = Sandbox.from_dict(response.json()) + + return response_200 + if response.status_code == 201: + response_201 = Sandbox.from_dict(response.json()) + + return response_201 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, Sandbox]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: ConnectSandbox, +) -> Response[Union[Error, Sandbox]]: + """Returns sandbox details. If the sandbox is paused, it will be resumed. TTL is only extended. + + Args: + sandbox_id (str): + body (ConnectSandbox): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, Sandbox]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: ConnectSandbox, +) -> Optional[Union[Error, Sandbox]]: + """Returns sandbox details. If the sandbox is paused, it will be resumed. TTL is only extended. + + Args: + sandbox_id (str): + body (ConnectSandbox): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, Sandbox] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: ConnectSandbox, +) -> Response[Union[Error, Sandbox]]: + """Returns sandbox details. If the sandbox is paused, it will be resumed. TTL is only extended. + + Args: + sandbox_id (str): + body (ConnectSandbox): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, Sandbox]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: ConnectSandbox, +) -> Optional[Union[Error, Sandbox]]: + """Returns sandbox details. If the sandbox is paused, it will be resumed. TTL is only extended. + + Args: + sandbox_id (str): + body (ConnectSandbox): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, Sandbox] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_pause.py b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_pause.py new file mode 100644 index 0000000..f2931d2 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_pause.py @@ -0,0 +1,187 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.sandbox_pause_request import SandboxPauseRequest +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, + *, + body: SandboxPauseRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/sandboxes/{sandbox_id}/pause", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + return response_409 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: SandboxPauseRequest, +) -> Response[Union[Any, Error]]: + """Pause the sandbox + + Args: + sandbox_id (str): + body (SandboxPauseRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: SandboxPauseRequest, +) -> Optional[Union[Any, Error]]: + """Pause the sandbox + + Args: + sandbox_id (str): + body (SandboxPauseRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: SandboxPauseRequest, +) -> Response[Union[Any, Error]]: + """Pause the sandbox + + Args: + sandbox_id (str): + body (SandboxPauseRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: SandboxPauseRequest, +) -> Optional[Union[Any, Error]]: + """Pause the sandbox + + Args: + sandbox_id (str): + body (SandboxPauseRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_refreshes.py b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_refreshes.py new file mode 100644 index 0000000..5b667ba --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_refreshes.py @@ -0,0 +1,181 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.post_sandboxes_sandbox_id_refreshes_body import ( + PostSandboxesSandboxIDRefreshesBody, +) +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, + *, + body: PostSandboxesSandboxIDRefreshesBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/sandboxes/{sandbox_id}/refreshes", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDRefreshesBody, +) -> Response[Union[Any, Error]]: + """Refresh the sandbox extending its time to live + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDRefreshesBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDRefreshesBody, +) -> Optional[Union[Any, Error]]: + """Refresh the sandbox extending its time to live + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDRefreshesBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDRefreshesBody, +) -> Response[Union[Any, Error]]: + """Refresh the sandbox extending its time to live + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDRefreshesBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDRefreshesBody, +) -> Optional[Union[Any, Error]]: + """Refresh the sandbox extending its time to live + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDRefreshesBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_resume.py b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_resume.py new file mode 100644 index 0000000..caca629 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_resume.py @@ -0,0 +1,189 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.resumed_sandbox import ResumedSandbox +from ...models.sandbox import Sandbox +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, + *, + body: ResumedSandbox, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/sandboxes/{sandbox_id}/resume", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, Sandbox]]: + if response.status_code == 201: + response_201 = Sandbox.from_dict(response.json()) + + return response_201 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + return response_409 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, Sandbox]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: ResumedSandbox, +) -> Response[Union[Error, Sandbox]]: + """Resume the sandbox + + Args: + sandbox_id (str): + body (ResumedSandbox): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, Sandbox]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: ResumedSandbox, +) -> Optional[Union[Error, Sandbox]]: + """Resume the sandbox + + Args: + sandbox_id (str): + body (ResumedSandbox): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, Sandbox] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: ResumedSandbox, +) -> Response[Union[Error, Sandbox]]: + """Resume the sandbox + + Args: + sandbox_id (str): + body (ResumedSandbox): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, Sandbox]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: ResumedSandbox, +) -> Optional[Union[Error, Sandbox]]: + """Resume the sandbox + + Args: + sandbox_id (str): + body (ResumedSandbox): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, Sandbox] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_snapshots.py b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_snapshots.py new file mode 100644 index 0000000..09b2e2a --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_snapshots.py @@ -0,0 +1,195 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.post_sandboxes_sandbox_id_snapshots_body import ( + PostSandboxesSandboxIDSnapshotsBody, +) +from ...models.snapshot_info import SnapshotInfo +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, + *, + body: PostSandboxesSandboxIDSnapshotsBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/sandboxes/{sandbox_id}/snapshots", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, SnapshotInfo]]: + if response.status_code == 201: + response_201 = SnapshotInfo.from_dict(response.json()) + + return response_201 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, SnapshotInfo]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDSnapshotsBody, +) -> Response[Union[Error, SnapshotInfo]]: + """Create a persistent snapshot from the sandbox's current state. Snapshots can be used to create new + sandboxes and persist beyond the original sandbox's lifetime. + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDSnapshotsBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, SnapshotInfo]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDSnapshotsBody, +) -> Optional[Union[Error, SnapshotInfo]]: + """Create a persistent snapshot from the sandbox's current state. Snapshots can be used to create new + sandboxes and persist beyond the original sandbox's lifetime. + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDSnapshotsBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, SnapshotInfo] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDSnapshotsBody, +) -> Response[Union[Error, SnapshotInfo]]: + """Create a persistent snapshot from the sandbox's current state. Snapshots can be used to create new + sandboxes and persist beyond the original sandbox's lifetime. + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDSnapshotsBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, SnapshotInfo]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDSnapshotsBody, +) -> Optional[Union[Error, SnapshotInfo]]: + """Create a persistent snapshot from the sandbox's current state. Snapshots can be used to create new + sandboxes and persist beyond the original sandbox's lifetime. + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDSnapshotsBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, SnapshotInfo] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_timeout.py b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_timeout.py new file mode 100644 index 0000000..2756f30 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_timeout.py @@ -0,0 +1,193 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.post_sandboxes_sandbox_id_timeout_body import ( + PostSandboxesSandboxIDTimeoutBody, +) +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, + *, + body: PostSandboxesSandboxIDTimeoutBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/sandboxes/{sandbox_id}/timeout", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDTimeoutBody, +) -> Response[Union[Any, Error]]: + """Set the timeout for the sandbox. The sandbox will expire x seconds from the time of the request. + Calling this method multiple times overwrites the TTL, each time using the current timestamp as the + starting point to measure the timeout duration. + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDTimeoutBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDTimeoutBody, +) -> Optional[Union[Any, Error]]: + """Set the timeout for the sandbox. The sandbox will expire x seconds from the time of the request. + Calling this method multiple times overwrites the TTL, each time using the current timestamp as the + starting point to measure the timeout duration. + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDTimeoutBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDTimeoutBody, +) -> Response[Union[Any, Error]]: + """Set the timeout for the sandbox. The sandbox will expire x seconds from the time of the request. + Calling this method multiple times overwrites the TTL, each time using the current timestamp as the + starting point to measure the timeout duration. + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDTimeoutBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDTimeoutBody, +) -> Optional[Union[Any, Error]]: + """Set the timeout for the sandbox. The sandbox will expire x seconds from the time of the request. + Calling this method multiple times overwrites the TTL, each time using the current timestamp as the + starting point to measure the timeout duration. + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDTimeoutBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/put_sandboxes_sandbox_id_network.py b/packages/python-sdk/e2b/api/client/api/sandboxes/put_sandboxes_sandbox_id_network.py new file mode 100644 index 0000000..64dad46 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/put_sandboxes_sandbox_id_network.py @@ -0,0 +1,199 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.sandbox_network_update_config import SandboxNetworkUpdateConfig +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, + *, + body: SandboxNetworkUpdateConfig, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "put", + "url": f"/sandboxes/{sandbox_id}/network", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + return response_409 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: SandboxNetworkUpdateConfig, +) -> Response[Union[Any, Error]]: + """Update the network configuration for a running sandbox. Replaces the current egress rules with the + provided configuration. Omitting field clears it. + + Args: + sandbox_id (str): + body (SandboxNetworkUpdateConfig): Network configuration update for a running sandbox. + Replaces the current egress rules with the provided configuration. Omitting a field clears + it. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: SandboxNetworkUpdateConfig, +) -> Optional[Union[Any, Error]]: + """Update the network configuration for a running sandbox. Replaces the current egress rules with the + provided configuration. Omitting field clears it. + + Args: + sandbox_id (str): + body (SandboxNetworkUpdateConfig): Network configuration update for a running sandbox. + Replaces the current egress rules with the provided configuration. Omitting a field clears + it. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: SandboxNetworkUpdateConfig, +) -> Response[Union[Any, Error]]: + """Update the network configuration for a running sandbox. Replaces the current egress rules with the + provided configuration. Omitting field clears it. + + Args: + sandbox_id (str): + body (SandboxNetworkUpdateConfig): Network configuration update for a running sandbox. + Replaces the current egress rules with the provided configuration. Omitting a field clears + it. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: SandboxNetworkUpdateConfig, +) -> Optional[Union[Any, Error]]: + """Update the network configuration for a running sandbox. Replaces the current egress rules with the + provided configuration. Omitting field clears it. + + Args: + sandbox_id (str): + body (SandboxNetworkUpdateConfig): Network configuration update for a running sandbox. + Replaces the current egress rules with the provided configuration. Omitting a field clears + it. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/snapshots/__init__.py b/packages/python-sdk/e2b/api/client/api/snapshots/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/snapshots/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/packages/python-sdk/e2b/api/client/api/snapshots/get_snapshots.py b/packages/python-sdk/e2b/api/client/api/snapshots/get_snapshots.py new file mode 100644 index 0000000..1b826e3 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/snapshots/get_snapshots.py @@ -0,0 +1,202 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.snapshot_info import SnapshotInfo +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + sandbox_id: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, + next_token: Union[Unset, str] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["sandboxID"] = sandbox_id + + params["limit"] = limit + + params["nextToken"] = next_token + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/snapshots", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, list["SnapshotInfo"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = SnapshotInfo.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, list["SnapshotInfo"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + sandbox_id: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, + next_token: Union[Unset, str] = UNSET, +) -> Response[Union[Error, list["SnapshotInfo"]]]: + """List all snapshots for the team + + Args: + sandbox_id (Union[Unset, str]): Filter snapshots by source sandbox ID + limit (Union[Unset, int]): Default: 100. + next_token (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['SnapshotInfo']]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + limit=limit, + next_token=next_token, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + sandbox_id: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, + next_token: Union[Unset, str] = UNSET, +) -> Optional[Union[Error, list["SnapshotInfo"]]]: + """List all snapshots for the team + + Args: + sandbox_id (Union[Unset, str]): Filter snapshots by source sandbox ID + limit (Union[Unset, int]): Default: 100. + next_token (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['SnapshotInfo']] + """ + + return sync_detailed( + client=client, + sandbox_id=sandbox_id, + limit=limit, + next_token=next_token, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + sandbox_id: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, + next_token: Union[Unset, str] = UNSET, +) -> Response[Union[Error, list["SnapshotInfo"]]]: + """List all snapshots for the team + + Args: + sandbox_id (Union[Unset, str]): Filter snapshots by source sandbox ID + limit (Union[Unset, int]): Default: 100. + next_token (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['SnapshotInfo']]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + limit=limit, + next_token=next_token, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + sandbox_id: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, + next_token: Union[Unset, str] = UNSET, +) -> Optional[Union[Error, list["SnapshotInfo"]]]: + """List all snapshots for the team + + Args: + sandbox_id (Union[Unset, str]): Filter snapshots by source sandbox ID + limit (Union[Unset, int]): Default: 100. + next_token (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['SnapshotInfo']] + """ + + return ( + await asyncio_detailed( + client=client, + sandbox_id=sandbox_id, + limit=limit, + next_token=next_token, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/tags/__init__.py b/packages/python-sdk/e2b/api/client/api/tags/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/tags/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/packages/python-sdk/e2b/api/client/api/tags/delete_templates_tags.py b/packages/python-sdk/e2b/api/client/api/tags/delete_templates_tags.py new file mode 100644 index 0000000..16a764a --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/tags/delete_templates_tags.py @@ -0,0 +1,174 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.delete_template_tags_request import DeleteTemplateTagsRequest +from ...models.error import Error +from ...types import Response + + +def _get_kwargs( + *, + body: DeleteTemplateTagsRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "delete", + "url": "/templates/tags", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: DeleteTemplateTagsRequest, +) -> Response[Union[Any, Error]]: + """Delete multiple tags from templates + + Args: + body (DeleteTemplateTagsRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: DeleteTemplateTagsRequest, +) -> Optional[Union[Any, Error]]: + """Delete multiple tags from templates + + Args: + body (DeleteTemplateTagsRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: DeleteTemplateTagsRequest, +) -> Response[Union[Any, Error]]: + """Delete multiple tags from templates + + Args: + body (DeleteTemplateTagsRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: DeleteTemplateTagsRequest, +) -> Optional[Union[Any, Error]]: + """Delete multiple tags from templates + + Args: + body (DeleteTemplateTagsRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/tags/get_templates_template_id_tags.py b/packages/python-sdk/e2b/api/client/api/tags/get_templates_template_id_tags.py new file mode 100644 index 0000000..60bc625 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/tags/get_templates_template_id_tags.py @@ -0,0 +1,172 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_tag import TemplateTag +from ...types import Response + + +def _get_kwargs( + template_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/templates/{template_id}/tags", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, list["TemplateTag"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = TemplateTag.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, list["TemplateTag"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, list["TemplateTag"]]]: + """List all tags for a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['TemplateTag']]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, list["TemplateTag"]]]: + """List all tags for a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['TemplateTag']] + """ + + return sync_detailed( + template_id=template_id, + client=client, + ).parsed + + +async def asyncio_detailed( + template_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, list["TemplateTag"]]]: + """List all tags for a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['TemplateTag']]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, list["TemplateTag"]]]: + """List all tags for a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['TemplateTag']] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + client=client, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/tags/post_templates_tags.py b/packages/python-sdk/e2b/api/client/api/tags/post_templates_tags.py new file mode 100644 index 0000000..fbc0ab4 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/tags/post_templates_tags.py @@ -0,0 +1,176 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.assign_template_tags_request import AssignTemplateTagsRequest +from ...models.assigned_template_tags import AssignedTemplateTags +from ...models.error import Error +from ...types import Response + + +def _get_kwargs( + *, + body: AssignTemplateTagsRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/templates/tags", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[AssignedTemplateTags, Error]]: + if response.status_code == 201: + response_201 = AssignedTemplateTags.from_dict(response.json()) + + return response_201 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[AssignedTemplateTags, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: AssignTemplateTagsRequest, +) -> Response[Union[AssignedTemplateTags, Error]]: + """Assign tag(s) to a template build + + Args: + body (AssignTemplateTagsRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[AssignedTemplateTags, Error]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: AssignTemplateTagsRequest, +) -> Optional[Union[AssignedTemplateTags, Error]]: + """Assign tag(s) to a template build + + Args: + body (AssignTemplateTagsRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[AssignedTemplateTags, Error] + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: AssignTemplateTagsRequest, +) -> Response[Union[AssignedTemplateTags, Error]]: + """Assign tag(s) to a template build + + Args: + body (AssignTemplateTagsRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[AssignedTemplateTags, Error]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: AssignTemplateTagsRequest, +) -> Optional[Union[AssignedTemplateTags, Error]]: + """Assign tag(s) to a template build + + Args: + body (AssignTemplateTagsRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[AssignedTemplateTags, Error] + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/__init__.py b/packages/python-sdk/e2b/api/client/api/templates/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/packages/python-sdk/e2b/api/client/api/templates/delete_templates_template_id.py b/packages/python-sdk/e2b/api/client/api/templates/delete_templates_template_id.py new file mode 100644 index 0000000..d73839c --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/delete_templates_template_id.py @@ -0,0 +1,157 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...types import Response + + +def _get_kwargs( + template_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "delete", + "url": f"/templates/{template_id}", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Delete a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, Error]]: + """Delete a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + template_id=template_id, + client=client, + ).parsed + + +async def asyncio_detailed( + template_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Delete a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, Error]]: + """Delete a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + client=client, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/get_templates.py b/packages/python-sdk/e2b/api/client/api/templates/get_templates.py new file mode 100644 index 0000000..a25db12 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/get_templates.py @@ -0,0 +1,172 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template import Template +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + team_id: Union[Unset, str] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["teamID"] = team_id + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/templates", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, list["Template"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = Template.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, list["Template"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + team_id: Union[Unset, str] = UNSET, +) -> Response[Union[Error, list["Template"]]]: + """List all templates + + Args: + team_id (Union[Unset, str]): Identifier of the team + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['Template']]] + """ + + kwargs = _get_kwargs( + team_id=team_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + team_id: Union[Unset, str] = UNSET, +) -> Optional[Union[Error, list["Template"]]]: + """List all templates + + Args: + team_id (Union[Unset, str]): Identifier of the team + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['Template']] + """ + + return sync_detailed( + client=client, + team_id=team_id, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + team_id: Union[Unset, str] = UNSET, +) -> Response[Union[Error, list["Template"]]]: + """List all templates + + Args: + team_id (Union[Unset, str]): Identifier of the team + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['Template']]] + """ + + kwargs = _get_kwargs( + team_id=team_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + team_id: Union[Unset, str] = UNSET, +) -> Optional[Union[Error, list["Template"]]]: + """List all templates + + Args: + team_id (Union[Unset, str]): Identifier of the team + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['Template']] + """ + + return ( + await asyncio_detailed( + client=client, + team_id=team_id, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/get_templates_aliases_alias.py b/packages/python-sdk/e2b/api/client/api/templates/get_templates_aliases_alias.py new file mode 100644 index 0000000..c862846 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/get_templates_aliases_alias.py @@ -0,0 +1,167 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_alias_response import TemplateAliasResponse +from ...types import Response + + +def _get_kwargs( + alias: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/templates/aliases/{alias}", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateAliasResponse]]: + if response.status_code == 200: + response_200 = TemplateAliasResponse.from_dict(response.json()) + + return response_200 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateAliasResponse]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + alias: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, TemplateAliasResponse]]: + """Check if template with given alias exists + + Args: + alias (str): Template alias + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateAliasResponse]] + """ + + kwargs = _get_kwargs( + alias=alias, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + alias: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, TemplateAliasResponse]]: + """Check if template with given alias exists + + Args: + alias (str): Template alias + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateAliasResponse] + """ + + return sync_detailed( + alias=alias, + client=client, + ).parsed + + +async def asyncio_detailed( + alias: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, TemplateAliasResponse]]: + """Check if template with given alias exists + + Args: + alias (str): Template alias + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateAliasResponse]] + """ + + kwargs = _get_kwargs( + alias=alias, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + alias: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, TemplateAliasResponse]]: + """Check if template with given alias exists + + Args: + alias (str): Template alias + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateAliasResponse] + """ + + return ( + await asyncio_detailed( + alias=alias, + client=client, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id.py b/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id.py new file mode 100644 index 0000000..875f9e9 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id.py @@ -0,0 +1,195 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_with_builds import TemplateWithBuilds +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + template_id: str, + *, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["nextToken"] = next_token + + params["limit"] = limit + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/templates/{template_id}", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateWithBuilds]]: + if response.status_code == 200: + response_200 = TemplateWithBuilds.from_dict(response.json()) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateWithBuilds]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + *, + client: AuthenticatedClient, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> Response[Union[Error, TemplateWithBuilds]]: + """List all builds for a template + + Args: + template_id (str): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateWithBuilds]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + next_token=next_token, + limit=limit, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + *, + client: AuthenticatedClient, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> Optional[Union[Error, TemplateWithBuilds]]: + """List all builds for a template + + Args: + template_id (str): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateWithBuilds] + """ + + return sync_detailed( + template_id=template_id, + client=client, + next_token=next_token, + limit=limit, + ).parsed + + +async def asyncio_detailed( + template_id: str, + *, + client: AuthenticatedClient, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> Response[Union[Error, TemplateWithBuilds]]: + """List all builds for a template + + Args: + template_id (str): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateWithBuilds]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + next_token=next_token, + limit=limit, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + *, + client: AuthenticatedClient, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> Optional[Union[Error, TemplateWithBuilds]]: + """List all builds for a template + + Args: + template_id (str): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateWithBuilds] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + client=client, + next_token=next_token, + limit=limit, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_builds_build_id_logs.py b/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_builds_build_id_logs.py new file mode 100644 index 0000000..1f4fee6 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_builds_build_id_logs.py @@ -0,0 +1,272 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.log_level import LogLevel +from ...models.logs_direction import LogsDirection +from ...models.logs_source import LogsSource +from ...models.template_build_logs_response import TemplateBuildLogsResponse +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + template_id: str, + build_id: str, + *, + cursor: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 100, + direction: Union[Unset, LogsDirection] = UNSET, + level: Union[Unset, LogLevel] = UNSET, + source: Union[Unset, LogsSource] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["cursor"] = cursor + + params["limit"] = limit + + json_direction: Union[Unset, str] = UNSET + if not isinstance(direction, Unset): + json_direction = direction.value + + params["direction"] = json_direction + + json_level: Union[Unset, str] = UNSET + if not isinstance(level, Unset): + json_level = level.value + + params["level"] = json_level + + json_source: Union[Unset, str] = UNSET + if not isinstance(source, Unset): + json_source = source.value + + params["source"] = json_source + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/templates/{template_id}/builds/{build_id}/logs", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateBuildLogsResponse]]: + if response.status_code == 200: + response_200 = TemplateBuildLogsResponse.from_dict(response.json()) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateBuildLogsResponse]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + cursor: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 100, + direction: Union[Unset, LogsDirection] = UNSET, + level: Union[Unset, LogLevel] = UNSET, + source: Union[Unset, LogsSource] = UNSET, +) -> Response[Union[Error, TemplateBuildLogsResponse]]: + """Get template build logs + + Args: + template_id (str): + build_id (str): + cursor (Union[Unset, int]): + limit (Union[Unset, int]): Default: 100. + direction (Union[Unset, LogsDirection]): Direction of the logs that should be returned + level (Union[Unset, LogLevel]): State of the sandbox + source (Union[Unset, LogsSource]): Source of the logs that should be returned + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateBuildLogsResponse]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + cursor=cursor, + limit=limit, + direction=direction, + level=level, + source=source, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + cursor: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 100, + direction: Union[Unset, LogsDirection] = UNSET, + level: Union[Unset, LogLevel] = UNSET, + source: Union[Unset, LogsSource] = UNSET, +) -> Optional[Union[Error, TemplateBuildLogsResponse]]: + """Get template build logs + + Args: + template_id (str): + build_id (str): + cursor (Union[Unset, int]): + limit (Union[Unset, int]): Default: 100. + direction (Union[Unset, LogsDirection]): Direction of the logs that should be returned + level (Union[Unset, LogLevel]): State of the sandbox + source (Union[Unset, LogsSource]): Source of the logs that should be returned + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateBuildLogsResponse] + """ + + return sync_detailed( + template_id=template_id, + build_id=build_id, + client=client, + cursor=cursor, + limit=limit, + direction=direction, + level=level, + source=source, + ).parsed + + +async def asyncio_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + cursor: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 100, + direction: Union[Unset, LogsDirection] = UNSET, + level: Union[Unset, LogLevel] = UNSET, + source: Union[Unset, LogsSource] = UNSET, +) -> Response[Union[Error, TemplateBuildLogsResponse]]: + """Get template build logs + + Args: + template_id (str): + build_id (str): + cursor (Union[Unset, int]): + limit (Union[Unset, int]): Default: 100. + direction (Union[Unset, LogsDirection]): Direction of the logs that should be returned + level (Union[Unset, LogLevel]): State of the sandbox + source (Union[Unset, LogsSource]): Source of the logs that should be returned + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateBuildLogsResponse]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + cursor=cursor, + limit=limit, + direction=direction, + level=level, + source=source, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + cursor: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 100, + direction: Union[Unset, LogsDirection] = UNSET, + level: Union[Unset, LogLevel] = UNSET, + source: Union[Unset, LogsSource] = UNSET, +) -> Optional[Union[Error, TemplateBuildLogsResponse]]: + """Get template build logs + + Args: + template_id (str): + build_id (str): + cursor (Union[Unset, int]): + limit (Union[Unset, int]): Default: 100. + direction (Union[Unset, LogsDirection]): Direction of the logs that should be returned + level (Union[Unset, LogLevel]): State of the sandbox + source (Union[Unset, LogsSource]): Source of the logs that should be returned + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateBuildLogsResponse] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + build_id=build_id, + client=client, + cursor=cursor, + limit=limit, + direction=direction, + level=level, + source=source, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_builds_build_id_status.py b/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_builds_build_id_status.py new file mode 100644 index 0000000..8185e2e --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_builds_build_id_status.py @@ -0,0 +1,232 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.log_level import LogLevel +from ...models.template_build_info import TemplateBuildInfo +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + template_id: str, + build_id: str, + *, + logs_offset: Union[Unset, int] = 0, + limit: Union[Unset, int] = 100, + level: Union[Unset, LogLevel] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["logsOffset"] = logs_offset + + params["limit"] = limit + + json_level: Union[Unset, str] = UNSET + if not isinstance(level, Unset): + json_level = level.value + + params["level"] = json_level + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/templates/{template_id}/builds/{build_id}/status", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateBuildInfo]]: + if response.status_code == 200: + response_200 = TemplateBuildInfo.from_dict(response.json()) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateBuildInfo]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + logs_offset: Union[Unset, int] = 0, + limit: Union[Unset, int] = 100, + level: Union[Unset, LogLevel] = UNSET, +) -> Response[Union[Error, TemplateBuildInfo]]: + """Get template build info + + Args: + template_id (str): + build_id (str): + logs_offset (Union[Unset, int]): Default: 0. + limit (Union[Unset, int]): Default: 100. + level (Union[Unset, LogLevel]): State of the sandbox + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateBuildInfo]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + logs_offset=logs_offset, + limit=limit, + level=level, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + logs_offset: Union[Unset, int] = 0, + limit: Union[Unset, int] = 100, + level: Union[Unset, LogLevel] = UNSET, +) -> Optional[Union[Error, TemplateBuildInfo]]: + """Get template build info + + Args: + template_id (str): + build_id (str): + logs_offset (Union[Unset, int]): Default: 0. + limit (Union[Unset, int]): Default: 100. + level (Union[Unset, LogLevel]): State of the sandbox + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateBuildInfo] + """ + + return sync_detailed( + template_id=template_id, + build_id=build_id, + client=client, + logs_offset=logs_offset, + limit=limit, + level=level, + ).parsed + + +async def asyncio_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + logs_offset: Union[Unset, int] = 0, + limit: Union[Unset, int] = 100, + level: Union[Unset, LogLevel] = UNSET, +) -> Response[Union[Error, TemplateBuildInfo]]: + """Get template build info + + Args: + template_id (str): + build_id (str): + logs_offset (Union[Unset, int]): Default: 0. + limit (Union[Unset, int]): Default: 100. + level (Union[Unset, LogLevel]): State of the sandbox + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateBuildInfo]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + logs_offset=logs_offset, + limit=limit, + level=level, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + logs_offset: Union[Unset, int] = 0, + limit: Union[Unset, int] = 100, + level: Union[Unset, LogLevel] = UNSET, +) -> Optional[Union[Error, TemplateBuildInfo]]: + """Get template build info + + Args: + template_id (str): + build_id (str): + logs_offset (Union[Unset, int]): Default: 0. + limit (Union[Unset, int]): Default: 100. + level (Union[Unset, LogLevel]): State of the sandbox + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateBuildInfo] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + build_id=build_id, + client=client, + logs_offset=logs_offset, + limit=limit, + level=level, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_files_hash.py b/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_files_hash.py new file mode 100644 index 0000000..0f6a1e4 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_files_hash.py @@ -0,0 +1,180 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_build_file_upload import TemplateBuildFileUpload +from ...types import Response + + +def _get_kwargs( + template_id: str, + hash_: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/templates/{template_id}/files/{hash_}", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateBuildFileUpload]]: + if response.status_code == 201: + response_201 = TemplateBuildFileUpload.from_dict(response.json()) + + return response_201 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateBuildFileUpload]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + hash_: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, TemplateBuildFileUpload]]: + """Get an upload link for a tar file containing build layer files + + Args: + template_id (str): + hash_ (str): Hash of the files + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateBuildFileUpload]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + hash_=hash_, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + hash_: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, TemplateBuildFileUpload]]: + """Get an upload link for a tar file containing build layer files + + Args: + template_id (str): + hash_ (str): Hash of the files + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateBuildFileUpload] + """ + + return sync_detailed( + template_id=template_id, + hash_=hash_, + client=client, + ).parsed + + +async def asyncio_detailed( + template_id: str, + hash_: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, TemplateBuildFileUpload]]: + """Get an upload link for a tar file containing build layer files + + Args: + template_id (str): + hash_ (str): Hash of the files + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateBuildFileUpload]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + hash_=hash_, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + hash_: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, TemplateBuildFileUpload]]: + """Get an upload link for a tar file containing build layer files + + Args: + template_id (str): + hash_ (str): Hash of the files + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateBuildFileUpload] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + hash_=hash_, + client=client, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/patch_templates_template_id.py b/packages/python-sdk/e2b/api/client/api/templates/patch_templates_template_id.py new file mode 100644 index 0000000..cf19391 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/patch_templates_template_id.py @@ -0,0 +1,183 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_update_request import TemplateUpdateRequest +from ...types import Response + + +def _get_kwargs( + template_id: str, + *, + body: TemplateUpdateRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": f"/templates/{template_id}", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 200: + response_200 = cast(Any, None) + return response_200 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateUpdateRequest, +) -> Response[Union[Any, Error]]: + """Update template + + Args: + template_id (str): + body (TemplateUpdateRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateUpdateRequest, +) -> Optional[Union[Any, Error]]: + """Update template + + Args: + template_id (str): + body (TemplateUpdateRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + template_id=template_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateUpdateRequest, +) -> Response[Union[Any, Error]]: + """Update template + + Args: + template_id (str): + body (TemplateUpdateRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateUpdateRequest, +) -> Optional[Union[Any, Error]]: + """Update template + + Args: + template_id (str): + body (TemplateUpdateRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/patch_v_2_templates_template_id.py b/packages/python-sdk/e2b/api/client/api/templates/patch_v_2_templates_template_id.py new file mode 100644 index 0000000..b262332 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/patch_v_2_templates_template_id.py @@ -0,0 +1,185 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_update_request import TemplateUpdateRequest +from ...models.template_update_response import TemplateUpdateResponse +from ...types import Response + + +def _get_kwargs( + template_id: str, + *, + body: TemplateUpdateRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": f"/v2/templates/{template_id}", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateUpdateResponse]]: + if response.status_code == 200: + response_200 = TemplateUpdateResponse.from_dict(response.json()) + + return response_200 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateUpdateResponse]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateUpdateRequest, +) -> Response[Union[Error, TemplateUpdateResponse]]: + """Update template + + Args: + template_id (str): + body (TemplateUpdateRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateUpdateResponse]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateUpdateRequest, +) -> Optional[Union[Error, TemplateUpdateResponse]]: + """Update template + + Args: + template_id (str): + body (TemplateUpdateRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateUpdateResponse] + """ + + return sync_detailed( + template_id=template_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateUpdateRequest, +) -> Response[Union[Error, TemplateUpdateResponse]]: + """Update template + + Args: + template_id (str): + body (TemplateUpdateRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateUpdateResponse]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateUpdateRequest, +) -> Optional[Union[Error, TemplateUpdateResponse]]: + """Update template + + Args: + template_id (str): + body (TemplateUpdateRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateUpdateResponse] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/post_templates.py b/packages/python-sdk/e2b/api/client/api/templates/post_templates.py new file mode 100644 index 0000000..015b0d9 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/post_templates.py @@ -0,0 +1,172 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_build_request import TemplateBuildRequest +from ...models.template_legacy import TemplateLegacy +from ...types import Response + + +def _get_kwargs( + *, + body: TemplateBuildRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/templates", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateLegacy]]: + if response.status_code == 202: + response_202 = TemplateLegacy.from_dict(response.json()) + + return response_202 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateLegacy]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Response[Union[Error, TemplateLegacy]]: + """Create a new template + + Args: + body (TemplateBuildRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateLegacy]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Optional[Union[Error, TemplateLegacy]]: + """Create a new template + + Args: + body (TemplateBuildRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateLegacy] + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Response[Union[Error, TemplateLegacy]]: + """Create a new template + + Args: + body (TemplateBuildRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateLegacy]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Optional[Union[Error, TemplateLegacy]]: + """Create a new template + + Args: + body (TemplateBuildRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateLegacy] + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/post_templates_template_id.py b/packages/python-sdk/e2b/api/client/api/templates/post_templates_template_id.py new file mode 100644 index 0000000..986a3b5 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/post_templates_template_id.py @@ -0,0 +1,181 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_build_request import TemplateBuildRequest +from ...models.template_legacy import TemplateLegacy +from ...types import Response + + +def _get_kwargs( + template_id: str, + *, + body: TemplateBuildRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/templates/{template_id}", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateLegacy]]: + if response.status_code == 202: + response_202 = TemplateLegacy.from_dict(response.json()) + + return response_202 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateLegacy]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Response[Union[Error, TemplateLegacy]]: + """Rebuild an template + + Args: + template_id (str): + body (TemplateBuildRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateLegacy]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Optional[Union[Error, TemplateLegacy]]: + """Rebuild an template + + Args: + template_id (str): + body (TemplateBuildRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateLegacy] + """ + + return sync_detailed( + template_id=template_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Response[Union[Error, TemplateLegacy]]: + """Rebuild an template + + Args: + template_id (str): + body (TemplateBuildRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateLegacy]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Optional[Union[Error, TemplateLegacy]]: + """Rebuild an template + + Args: + template_id (str): + body (TemplateBuildRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateLegacy] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/post_templates_template_id_builds_build_id.py b/packages/python-sdk/e2b/api/client/api/templates/post_templates_template_id_builds_build_id.py new file mode 100644 index 0000000..d2709ff --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/post_templates_template_id_builds_build_id.py @@ -0,0 +1,170 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...types import Response + + +def _get_kwargs( + template_id: str, + build_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/templates/{template_id}/builds/{build_id}", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 202: + response_202 = cast(Any, None) + return response_202 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + template_id=template_id, + build_id=build_id, + client=client, + ).parsed + + +async def asyncio_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + build_id=build_id, + client=client, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/post_v2_templates.py b/packages/python-sdk/e2b/api/client/api/templates/post_v2_templates.py new file mode 100644 index 0000000..86b5ebe --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/post_v2_templates.py @@ -0,0 +1,172 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_build_request_v2 import TemplateBuildRequestV2 +from ...models.template_legacy import TemplateLegacy +from ...types import Response + + +def _get_kwargs( + *, + body: TemplateBuildRequestV2, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/v2/templates", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateLegacy]]: + if response.status_code == 202: + response_202 = TemplateLegacy.from_dict(response.json()) + + return response_202 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateLegacy]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV2, +) -> Response[Union[Error, TemplateLegacy]]: + """Create a new template + + Args: + body (TemplateBuildRequestV2): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateLegacy]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV2, +) -> Optional[Union[Error, TemplateLegacy]]: + """Create a new template + + Args: + body (TemplateBuildRequestV2): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateLegacy] + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV2, +) -> Response[Union[Error, TemplateLegacy]]: + """Create a new template + + Args: + body (TemplateBuildRequestV2): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateLegacy]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV2, +) -> Optional[Union[Error, TemplateLegacy]]: + """Create a new template + + Args: + body (TemplateBuildRequestV2): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateLegacy] + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/post_v3_templates.py b/packages/python-sdk/e2b/api/client/api/templates/post_v3_templates.py new file mode 100644 index 0000000..d46b256 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/post_v3_templates.py @@ -0,0 +1,176 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_build_request_v3 import TemplateBuildRequestV3 +from ...models.template_request_response_v3 import TemplateRequestResponseV3 +from ...types import Response + + +def _get_kwargs( + *, + body: TemplateBuildRequestV3, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/v3/templates", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateRequestResponseV3]]: + if response.status_code == 202: + response_202 = TemplateRequestResponseV3.from_dict(response.json()) + + return response_202 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateRequestResponseV3]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV3, +) -> Response[Union[Error, TemplateRequestResponseV3]]: + """Create a new template + + Args: + body (TemplateBuildRequestV3): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateRequestResponseV3]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV3, +) -> Optional[Union[Error, TemplateRequestResponseV3]]: + """Create a new template + + Args: + body (TemplateBuildRequestV3): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateRequestResponseV3] + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV3, +) -> Response[Union[Error, TemplateRequestResponseV3]]: + """Create a new template + + Args: + body (TemplateBuildRequestV3): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateRequestResponseV3]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV3, +) -> Optional[Union[Error, TemplateRequestResponseV3]]: + """Create a new template + + Args: + body (TemplateBuildRequestV3): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateRequestResponseV3] + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/post_v_2_templates_template_id_builds_build_id.py b/packages/python-sdk/e2b/api/client/api/templates/post_v_2_templates_template_id_builds_build_id.py new file mode 100644 index 0000000..0d3da1f --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/post_v_2_templates_template_id_builds_build_id.py @@ -0,0 +1,192 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_build_start_v2 import TemplateBuildStartV2 +from ...types import Response + + +def _get_kwargs( + template_id: str, + build_id: str, + *, + body: TemplateBuildStartV2, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/v2/templates/{template_id}/builds/{build_id}", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 202: + response_202 = cast(Any, None) + return response_202 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildStartV2, +) -> Response[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + body (TemplateBuildStartV2): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildStartV2, +) -> Optional[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + body (TemplateBuildStartV2): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + template_id=template_id, + build_id=build_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildStartV2, +) -> Response[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + body (TemplateBuildStartV2): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildStartV2, +) -> Optional[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + body (TemplateBuildStartV2): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + build_id=build_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/volumes/__init__.py b/packages/python-sdk/e2b/api/client/api/volumes/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/volumes/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/packages/python-sdk/e2b/api/client/api/volumes/delete_volumes_volume_id.py b/packages/python-sdk/e2b/api/client/api/volumes/delete_volumes_volume_id.py new file mode 100644 index 0000000..d79ca75 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/volumes/delete_volumes_volume_id.py @@ -0,0 +1,161 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...types import Response + + +def _get_kwargs( + volume_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "delete", + "url": f"/volumes/{volume_id}", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + volume_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Delete a team volume + + Args: + volume_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + volume_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, Error]]: + """Delete a team volume + + Args: + volume_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + volume_id=volume_id, + client=client, + ).parsed + + +async def asyncio_detailed( + volume_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Delete a team volume + + Args: + volume_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + volume_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, Error]]: + """Delete a team volume + + Args: + volume_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + volume_id=volume_id, + client=client, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/volumes/get_volumes.py b/packages/python-sdk/e2b/api/client/api/volumes/get_volumes.py new file mode 100644 index 0000000..2c794b5 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/volumes/get_volumes.py @@ -0,0 +1,140 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.volume import Volume +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/volumes", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, list["Volume"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = Volume.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, list["Volume"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, +) -> Response[Union[Error, list["Volume"]]]: + """List all team volumes + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['Volume']]] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, list["Volume"]]]: + """List all team volumes + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['Volume']] + """ + + return sync_detailed( + client=client, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, +) -> Response[Union[Error, list["Volume"]]]: + """List all team volumes + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['Volume']]] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, list["Volume"]]]: + """List all team volumes + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['Volume']] + """ + + return ( + await asyncio_detailed( + client=client, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/volumes/get_volumes_volume_id.py b/packages/python-sdk/e2b/api/client/api/volumes/get_volumes_volume_id.py new file mode 100644 index 0000000..7fc5ebf --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/volumes/get_volumes_volume_id.py @@ -0,0 +1,163 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.volume_and_token import VolumeAndToken +from ...types import Response + + +def _get_kwargs( + volume_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/volumes/{volume_id}", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, VolumeAndToken]]: + if response.status_code == 200: + response_200 = VolumeAndToken.from_dict(response.json()) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, VolumeAndToken]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + volume_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, VolumeAndToken]]: + """Get team volume info + + Args: + volume_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, VolumeAndToken]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + volume_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, VolumeAndToken]]: + """Get team volume info + + Args: + volume_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, VolumeAndToken] + """ + + return sync_detailed( + volume_id=volume_id, + client=client, + ).parsed + + +async def asyncio_detailed( + volume_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, VolumeAndToken]]: + """Get team volume info + + Args: + volume_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, VolumeAndToken]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + volume_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, VolumeAndToken]]: + """Get team volume info + + Args: + volume_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, VolumeAndToken] + """ + + return ( + await asyncio_detailed( + volume_id=volume_id, + client=client, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/volumes/post_volumes.py b/packages/python-sdk/e2b/api/client/api/volumes/post_volumes.py new file mode 100644 index 0000000..74e1c8f --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/volumes/post_volumes.py @@ -0,0 +1,172 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.new_volume import NewVolume +from ...models.volume_and_token import VolumeAndToken +from ...types import Response + + +def _get_kwargs( + *, + body: NewVolume, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/volumes", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, VolumeAndToken]]: + if response.status_code == 201: + response_201 = VolumeAndToken.from_dict(response.json()) + + return response_201 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, VolumeAndToken]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: NewVolume, +) -> Response[Union[Error, VolumeAndToken]]: + """Create a new team volume + + Args: + body (NewVolume): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, VolumeAndToken]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: NewVolume, +) -> Optional[Union[Error, VolumeAndToken]]: + """Create a new team volume + + Args: + body (NewVolume): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, VolumeAndToken] + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: NewVolume, +) -> Response[Union[Error, VolumeAndToken]]: + """Create a new team volume + + Args: + body (NewVolume): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, VolumeAndToken]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: NewVolume, +) -> Optional[Union[Error, VolumeAndToken]]: + """Create a new team volume + + Args: + body (NewVolume): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, VolumeAndToken] + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/client.py b/packages/python-sdk/e2b/api/client/client.py new file mode 100644 index 0000000..eeffd00 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/client.py @@ -0,0 +1,286 @@ +import ssl +from typing import Any, Optional, Union + +import httpx +from attrs import define, evolve, field + + +@define +class Client: + """A class for keeping track of data related to the API + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: Optional[httpx.Timeout] = field( + default=None, kw_only=True, alias="timeout" + ) + _verify_ssl: Union[str, bool, ssl.SSLContext] = field( + default=True, kw_only=True, alias="verify_ssl" + ) + _follow_redirects: bool = field( + default=False, kw_only=True, alias="follow_redirects" + ) + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: Optional[httpx.Client] = field(default=None, init=False) + _async_client: Optional[httpx.AsyncClient] = field(default=None, init=False) + + def with_headers(self, headers: dict[str, str]) -> "Client": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "Client": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "Client": + """Get a new client matching this one with a new timeout (in seconds)""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "Client": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "Client": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client": + """Manually the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "Client": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) + + +@define +class AuthenticatedClient: + """A Client which has been authenticated for use on secured endpoints + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + token: The token to use for authentication + prefix: The prefix to use for the Authorization header + auth_header_name: The name of the Authorization header + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: Optional[httpx.Timeout] = field( + default=None, kw_only=True, alias="timeout" + ) + _verify_ssl: Union[str, bool, ssl.SSLContext] = field( + default=True, kw_only=True, alias="verify_ssl" + ) + _follow_redirects: bool = field( + default=False, kw_only=True, alias="follow_redirects" + ) + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: Optional[httpx.Client] = field(default=None, init=False) + _async_client: Optional[httpx.AsyncClient] = field(default=None, init=False) + + token: str + prefix: str = "Bearer" + auth_header_name: str = "Authorization" + + def with_headers(self, headers: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient": + """Get a new client matching this one with a new timeout (in seconds)""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._headers[self.auth_header_name] = ( + f"{self.prefix} {self.token}" if self.prefix else self.token + ) + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "AuthenticatedClient": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client( + self, async_client: httpx.AsyncClient + ) -> "AuthenticatedClient": + """Manually the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._headers[self.auth_header_name] = ( + f"{self.prefix} {self.token}" if self.prefix else self.token + ) + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "AuthenticatedClient": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) diff --git a/packages/python-sdk/e2b/api/client/errors.py b/packages/python-sdk/e2b/api/client/errors.py new file mode 100644 index 0000000..5f92e76 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/errors.py @@ -0,0 +1,16 @@ +"""Contains shared errors types that can be raised from API functions""" + + +class UnexpectedStatus(Exception): + """Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True""" + + def __init__(self, status_code: int, content: bytes): + self.status_code = status_code + self.content = content + + super().__init__( + f"Unexpected status code: {status_code}\n\nResponse content:\n{content.decode(errors='ignore')}" + ) + + +__all__ = ["UnexpectedStatus"] diff --git a/packages/python-sdk/e2b/api/client/models/__init__.py b/packages/python-sdk/e2b/api/client/models/__init__.py new file mode 100644 index 0000000..1f18254 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/__init__.py @@ -0,0 +1,185 @@ +"""Contains all the data models used in inputs/outputs""" + +from .admin_build_cancel_result import AdminBuildCancelResult +from .admin_sandbox_kill_result import AdminSandboxKillResult +from .assign_template_tags_request import AssignTemplateTagsRequest +from .assigned_template_tags import AssignedTemplateTags +from .aws_registry import AWSRegistry +from .aws_registry_type import AWSRegistryType +from .build_log_entry import BuildLogEntry +from .build_status_reason import BuildStatusReason +from .connect_sandbox import ConnectSandbox +from .created_access_token import CreatedAccessToken +from .created_team_api_key import CreatedTeamAPIKey +from .delete_template_tags_request import DeleteTemplateTagsRequest +from .disk_metrics import DiskMetrics +from .error import Error +from .gcp_registry import GCPRegistry +from .gcp_registry_type import GCPRegistryType +from .general_registry import GeneralRegistry +from .general_registry_type import GeneralRegistryType +from .identifier_masking_details import IdentifierMaskingDetails +from .listed_sandbox import ListedSandbox +from .log_level import LogLevel +from .logs_direction import LogsDirection +from .logs_source import LogsSource +from .machine_info import MachineInfo +from .max_team_metric import MaxTeamMetric +from .mcp_type_0 import McpType0 +from .new_access_token import NewAccessToken +from .new_sandbox import NewSandbox +from .new_team_api_key import NewTeamAPIKey +from .new_volume import NewVolume +from .node import Node +from .node_detail import NodeDetail +from .node_metrics import NodeMetrics +from .node_status import NodeStatus +from .node_status_change import NodeStatusChange +from .post_sandboxes_sandbox_id_refreshes_body import ( + PostSandboxesSandboxIDRefreshesBody, +) +from .post_sandboxes_sandbox_id_snapshots_body import ( + PostSandboxesSandboxIDSnapshotsBody, +) +from .post_sandboxes_sandbox_id_timeout_body import PostSandboxesSandboxIDTimeoutBody +from .resumed_sandbox import ResumedSandbox +from .sandbox import Sandbox +from .sandbox_auto_resume_config import SandboxAutoResumeConfig +from .sandbox_detail import SandboxDetail +from .sandbox_lifecycle import SandboxLifecycle +from .sandbox_log import SandboxLog +from .sandbox_log_entry import SandboxLogEntry +from .sandbox_log_entry_fields import SandboxLogEntryFields +from .sandbox_logs import SandboxLogs +from .sandbox_logs_v2_response import SandboxLogsV2Response +from .sandbox_metric import SandboxMetric +from .sandbox_network_config import SandboxNetworkConfig +from .sandbox_network_config_rules import SandboxNetworkConfigRules +from .sandbox_network_rule import SandboxNetworkRule +from .sandbox_network_transform import SandboxNetworkTransform +from .sandbox_network_transform_headers import SandboxNetworkTransformHeaders +from .sandbox_network_update_config import SandboxNetworkUpdateConfig +from .sandbox_network_update_config_rules import SandboxNetworkUpdateConfigRules +from .sandbox_on_timeout import SandboxOnTimeout +from .sandbox_pause_request import SandboxPauseRequest +from .sandbox_state import SandboxState +from .sandbox_volume_mount import SandboxVolumeMount +from .sandboxes_with_metrics import SandboxesWithMetrics +from .snapshot_info import SnapshotInfo +from .team import Team +from .team_api_key import TeamAPIKey +from .team_metric import TeamMetric +from .team_user import TeamUser +from .template import Template +from .template_alias_response import TemplateAliasResponse +from .template_build import TemplateBuild +from .template_build_file_upload import TemplateBuildFileUpload +from .template_build_info import TemplateBuildInfo +from .template_build_logs_response import TemplateBuildLogsResponse +from .template_build_request import TemplateBuildRequest +from .template_build_request_v2 import TemplateBuildRequestV2 +from .template_build_request_v3 import TemplateBuildRequestV3 +from .template_build_start_v2 import TemplateBuildStartV2 +from .template_build_status import TemplateBuildStatus +from .template_legacy import TemplateLegacy +from .template_request_response_v3 import TemplateRequestResponseV3 +from .template_step import TemplateStep +from .template_tag import TemplateTag +from .template_update_request import TemplateUpdateRequest +from .template_update_response import TemplateUpdateResponse +from .template_with_builds import TemplateWithBuilds +from .update_team_api_key import UpdateTeamAPIKey +from .volume import Volume +from .volume_and_token import VolumeAndToken +from .volume_token import VolumeToken + +__all__ = ( + "AdminBuildCancelResult", + "AdminSandboxKillResult", + "AssignedTemplateTags", + "AssignTemplateTagsRequest", + "AWSRegistry", + "AWSRegistryType", + "BuildLogEntry", + "BuildStatusReason", + "ConnectSandbox", + "CreatedAccessToken", + "CreatedTeamAPIKey", + "DeleteTemplateTagsRequest", + "DiskMetrics", + "Error", + "GCPRegistry", + "GCPRegistryType", + "GeneralRegistry", + "GeneralRegistryType", + "IdentifierMaskingDetails", + "ListedSandbox", + "LogLevel", + "LogsDirection", + "LogsSource", + "MachineInfo", + "MaxTeamMetric", + "McpType0", + "NewAccessToken", + "NewSandbox", + "NewTeamAPIKey", + "NewVolume", + "Node", + "NodeDetail", + "NodeMetrics", + "NodeStatus", + "NodeStatusChange", + "PostSandboxesSandboxIDRefreshesBody", + "PostSandboxesSandboxIDSnapshotsBody", + "PostSandboxesSandboxIDTimeoutBody", + "ResumedSandbox", + "Sandbox", + "SandboxAutoResumeConfig", + "SandboxDetail", + "SandboxesWithMetrics", + "SandboxLifecycle", + "SandboxLog", + "SandboxLogEntry", + "SandboxLogEntryFields", + "SandboxLogs", + "SandboxLogsV2Response", + "SandboxMetric", + "SandboxNetworkConfig", + "SandboxNetworkConfigRules", + "SandboxNetworkRule", + "SandboxNetworkTransform", + "SandboxNetworkTransformHeaders", + "SandboxNetworkUpdateConfig", + "SandboxNetworkUpdateConfigRules", + "SandboxOnTimeout", + "SandboxPauseRequest", + "SandboxState", + "SandboxVolumeMount", + "SnapshotInfo", + "Team", + "TeamAPIKey", + "TeamMetric", + "TeamUser", + "Template", + "TemplateAliasResponse", + "TemplateBuild", + "TemplateBuildFileUpload", + "TemplateBuildInfo", + "TemplateBuildLogsResponse", + "TemplateBuildRequest", + "TemplateBuildRequestV2", + "TemplateBuildRequestV3", + "TemplateBuildStartV2", + "TemplateBuildStatus", + "TemplateLegacy", + "TemplateRequestResponseV3", + "TemplateStep", + "TemplateTag", + "TemplateUpdateRequest", + "TemplateUpdateResponse", + "TemplateWithBuilds", + "UpdateTeamAPIKey", + "Volume", + "VolumeAndToken", + "VolumeToken", +) diff --git a/packages/python-sdk/e2b/api/client/models/admin_build_cancel_result.py b/packages/python-sdk/e2b/api/client/models/admin_build_cancel_result.py new file mode 100644 index 0000000..ad74294 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/admin_build_cancel_result.py @@ -0,0 +1,67 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="AdminBuildCancelResult") + + +@_attrs_define +class AdminBuildCancelResult: + """ + Attributes: + cancelled_count (int): Number of builds successfully cancelled + failed_count (int): Number of builds that failed to cancel + """ + + cancelled_count: int + failed_count: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + cancelled_count = self.cancelled_count + + failed_count = self.failed_count + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "cancelledCount": cancelled_count, + "failedCount": failed_count, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + cancelled_count = d.pop("cancelledCount") + + failed_count = d.pop("failedCount") + + admin_build_cancel_result = cls( + cancelled_count=cancelled_count, + failed_count=failed_count, + ) + + admin_build_cancel_result.additional_properties = d + return admin_build_cancel_result + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/admin_sandbox_kill_result.py b/packages/python-sdk/e2b/api/client/models/admin_sandbox_kill_result.py new file mode 100644 index 0000000..ac1df89 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/admin_sandbox_kill_result.py @@ -0,0 +1,67 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="AdminSandboxKillResult") + + +@_attrs_define +class AdminSandboxKillResult: + """ + Attributes: + failed_count (int): Number of sandboxes that failed to kill + killed_count (int): Number of sandboxes successfully killed + """ + + failed_count: int + killed_count: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + failed_count = self.failed_count + + killed_count = self.killed_count + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "failedCount": failed_count, + "killedCount": killed_count, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + failed_count = d.pop("failedCount") + + killed_count = d.pop("killedCount") + + admin_sandbox_kill_result = cls( + failed_count=failed_count, + killed_count=killed_count, + ) + + admin_sandbox_kill_result.additional_properties = d + return admin_sandbox_kill_result + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/assign_template_tags_request.py b/packages/python-sdk/e2b/api/client/models/assign_template_tags_request.py new file mode 100644 index 0000000..3fb10a0 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/assign_template_tags_request.py @@ -0,0 +1,67 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="AssignTemplateTagsRequest") + + +@_attrs_define +class AssignTemplateTagsRequest: + """ + Attributes: + tags (list[str]): Tags to assign to the template + target (str): Target template in "name:tag" format + """ + + tags: list[str] + target: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + tags = self.tags + + target = self.target + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "tags": tags, + "target": target, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + tags = cast(list[str], d.pop("tags")) + + target = d.pop("target") + + assign_template_tags_request = cls( + tags=tags, + target=target, + ) + + assign_template_tags_request.additional_properties = d + return assign_template_tags_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/assigned_template_tags.py b/packages/python-sdk/e2b/api/client/models/assigned_template_tags.py new file mode 100644 index 0000000..7f9ecaa --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/assigned_template_tags.py @@ -0,0 +1,68 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, cast +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="AssignedTemplateTags") + + +@_attrs_define +class AssignedTemplateTags: + """ + Attributes: + build_id (UUID): Identifier of the build associated with these tags + tags (list[str]): Assigned tags of the template + """ + + build_id: UUID + tags: list[str] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + build_id = str(self.build_id) + + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "buildID": build_id, + "tags": tags, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + build_id = UUID(d.pop("buildID")) + + tags = cast(list[str], d.pop("tags")) + + assigned_template_tags = cls( + build_id=build_id, + tags=tags, + ) + + assigned_template_tags.additional_properties = d + return assigned_template_tags + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/aws_registry.py b/packages/python-sdk/e2b/api/client/models/aws_registry.py new file mode 100644 index 0000000..ef1ea97 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/aws_registry.py @@ -0,0 +1,85 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.aws_registry_type import AWSRegistryType + +T = TypeVar("T", bound="AWSRegistry") + + +@_attrs_define +class AWSRegistry: + """ + Attributes: + aws_access_key_id (str): AWS Access Key ID for ECR authentication + aws_region (str): AWS Region where the ECR registry is located + aws_secret_access_key (str): AWS Secret Access Key for ECR authentication + type_ (AWSRegistryType): Type of registry authentication + """ + + aws_access_key_id: str + aws_region: str + aws_secret_access_key: str + type_: AWSRegistryType + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + aws_access_key_id = self.aws_access_key_id + + aws_region = self.aws_region + + aws_secret_access_key = self.aws_secret_access_key + + type_ = self.type_.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "awsAccessKeyId": aws_access_key_id, + "awsRegion": aws_region, + "awsSecretAccessKey": aws_secret_access_key, + "type": type_, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + aws_access_key_id = d.pop("awsAccessKeyId") + + aws_region = d.pop("awsRegion") + + aws_secret_access_key = d.pop("awsSecretAccessKey") + + type_ = AWSRegistryType(d.pop("type")) + + aws_registry = cls( + aws_access_key_id=aws_access_key_id, + aws_region=aws_region, + aws_secret_access_key=aws_secret_access_key, + type_=type_, + ) + + aws_registry.additional_properties = d + return aws_registry + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/aws_registry_type.py b/packages/python-sdk/e2b/api/client/models/aws_registry_type.py new file mode 100644 index 0000000..6815bbc --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/aws_registry_type.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class AWSRegistryType(str, Enum): + AWS = "aws" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/api/client/models/build_log_entry.py b/packages/python-sdk/e2b/api/client/models/build_log_entry.py new file mode 100644 index 0000000..35cae95 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/build_log_entry.py @@ -0,0 +1,89 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.log_level import LogLevel +from ..types import UNSET, Unset + +T = TypeVar("T", bound="BuildLogEntry") + + +@_attrs_define +class BuildLogEntry: + """ + Attributes: + level (LogLevel): State of the sandbox + message (str): Log message content + timestamp (datetime.datetime): Timestamp of the log entry + step (Union[Unset, str]): Step in the build process related to the log entry + """ + + level: LogLevel + message: str + timestamp: datetime.datetime + step: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + level = self.level.value + + message = self.message + + timestamp = self.timestamp.isoformat() + + step = self.step + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "level": level, + "message": message, + "timestamp": timestamp, + } + ) + if step is not UNSET: + field_dict["step"] = step + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + level = LogLevel(d.pop("level")) + + message = d.pop("message") + + timestamp = isoparse(d.pop("timestamp")) + + step = d.pop("step", UNSET) + + build_log_entry = cls( + level=level, + message=message, + timestamp=timestamp, + step=step, + ) + + build_log_entry.additional_properties = d + return build_log_entry + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/build_status_reason.py b/packages/python-sdk/e2b/api/client/models/build_status_reason.py new file mode 100644 index 0000000..e2af403 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/build_status_reason.py @@ -0,0 +1,95 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.build_log_entry import BuildLogEntry + + +T = TypeVar("T", bound="BuildStatusReason") + + +@_attrs_define +class BuildStatusReason: + """ + Attributes: + message (str): Message with the status reason, currently reporting only for error status + log_entries (Union[Unset, list['BuildLogEntry']]): Log entries related to the status reason + step (Union[Unset, str]): Step that failed + """ + + message: str + log_entries: Union[Unset, list["BuildLogEntry"]] = UNSET + step: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + message = self.message + + log_entries: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.log_entries, Unset): + log_entries = [] + for log_entries_item_data in self.log_entries: + log_entries_item = log_entries_item_data.to_dict() + log_entries.append(log_entries_item) + + step = self.step + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "message": message, + } + ) + if log_entries is not UNSET: + field_dict["logEntries"] = log_entries + if step is not UNSET: + field_dict["step"] = step + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.build_log_entry import BuildLogEntry + + d = dict(src_dict) + message = d.pop("message") + + log_entries = [] + _log_entries = d.pop("logEntries", UNSET) + for log_entries_item_data in _log_entries or []: + log_entries_item = BuildLogEntry.from_dict(log_entries_item_data) + + log_entries.append(log_entries_item) + + step = d.pop("step", UNSET) + + build_status_reason = cls( + message=message, + log_entries=log_entries, + step=step, + ) + + build_status_reason.additional_properties = d + return build_status_reason + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/connect_sandbox.py b/packages/python-sdk/e2b/api/client/models/connect_sandbox.py new file mode 100644 index 0000000..de7a8f8 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/connect_sandbox.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="ConnectSandbox") + + +@_attrs_define +class ConnectSandbox: + """ + Attributes: + timeout (int): Timeout in seconds from the current time after which the sandbox should expire + """ + + timeout: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + timeout = self.timeout + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "timeout": timeout, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + timeout = d.pop("timeout") + + connect_sandbox = cls( + timeout=timeout, + ) + + connect_sandbox.additional_properties = d + return connect_sandbox + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/created_access_token.py b/packages/python-sdk/e2b/api/client/models/created_access_token.py new file mode 100644 index 0000000..04224ad --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/created_access_token.py @@ -0,0 +1,100 @@ +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +if TYPE_CHECKING: + from ..models.identifier_masking_details import IdentifierMaskingDetails + + +T = TypeVar("T", bound="CreatedAccessToken") + + +@_attrs_define +class CreatedAccessToken: + """ + Attributes: + created_at (datetime.datetime): Timestamp of access token creation + id (UUID): Identifier of the access token + mask (IdentifierMaskingDetails): + name (str): Name of the access token + token (str): The fully created access token + """ + + created_at: datetime.datetime + id: UUID + mask: "IdentifierMaskingDetails" + name: str + token: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + created_at = self.created_at.isoformat() + + id = str(self.id) + + mask = self.mask.to_dict() + + name = self.name + + token = self.token + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "createdAt": created_at, + "id": id, + "mask": mask, + "name": name, + "token": token, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.identifier_masking_details import IdentifierMaskingDetails + + d = dict(src_dict) + created_at = isoparse(d.pop("createdAt")) + + id = UUID(d.pop("id")) + + mask = IdentifierMaskingDetails.from_dict(d.pop("mask")) + + name = d.pop("name") + + token = d.pop("token") + + created_access_token = cls( + created_at=created_at, + id=id, + mask=mask, + name=name, + token=token, + ) + + created_access_token.additional_properties = d + return created_access_token + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/created_team_api_key.py b/packages/python-sdk/e2b/api/client/models/created_team_api_key.py new file mode 100644 index 0000000..f1d4150 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/created_team_api_key.py @@ -0,0 +1,166 @@ +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.identifier_masking_details import IdentifierMaskingDetails + from ..models.team_user import TeamUser + + +T = TypeVar("T", bound="CreatedTeamAPIKey") + + +@_attrs_define +class CreatedTeamAPIKey: + """ + Attributes: + created_at (datetime.datetime): Timestamp of API key creation + id (UUID): Identifier of the API key + key (str): Raw value of the API key + mask (IdentifierMaskingDetails): + name (str): Name of the API key + created_by (Union['TeamUser', None, Unset]): + last_used (Union[None, Unset, datetime.datetime]): Last time this API key was used + """ + + created_at: datetime.datetime + id: UUID + key: str + mask: "IdentifierMaskingDetails" + name: str + created_by: Union["TeamUser", None, Unset] = UNSET + last_used: Union[None, Unset, datetime.datetime] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.team_user import TeamUser + + created_at = self.created_at.isoformat() + + id = str(self.id) + + key = self.key + + mask = self.mask.to_dict() + + name = self.name + + created_by: Union[None, Unset, dict[str, Any]] + if isinstance(self.created_by, Unset): + created_by = UNSET + elif isinstance(self.created_by, TeamUser): + created_by = self.created_by.to_dict() + else: + created_by = self.created_by + + last_used: Union[None, Unset, str] + if isinstance(self.last_used, Unset): + last_used = UNSET + elif isinstance(self.last_used, datetime.datetime): + last_used = self.last_used.isoformat() + else: + last_used = self.last_used + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "createdAt": created_at, + "id": id, + "key": key, + "mask": mask, + "name": name, + } + ) + if created_by is not UNSET: + field_dict["createdBy"] = created_by + if last_used is not UNSET: + field_dict["lastUsed"] = last_used + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.identifier_masking_details import IdentifierMaskingDetails + from ..models.team_user import TeamUser + + d = dict(src_dict) + created_at = isoparse(d.pop("createdAt")) + + id = UUID(d.pop("id")) + + key = d.pop("key") + + mask = IdentifierMaskingDetails.from_dict(d.pop("mask")) + + name = d.pop("name") + + def _parse_created_by(data: object) -> Union["TeamUser", None, Unset]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + created_by_type_1 = TeamUser.from_dict(data) + + return created_by_type_1 + except: # noqa: E722 + pass + return cast(Union["TeamUser", None, Unset], data) + + created_by = _parse_created_by(d.pop("createdBy", UNSET)) + + def _parse_last_used(data: object) -> Union[None, Unset, datetime.datetime]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + last_used_type_0 = isoparse(data) + + return last_used_type_0 + except: # noqa: E722 + pass + return cast(Union[None, Unset, datetime.datetime], data) + + last_used = _parse_last_used(d.pop("lastUsed", UNSET)) + + created_team_api_key = cls( + created_at=created_at, + id=id, + key=key, + mask=mask, + name=name, + created_by=created_by, + last_used=last_used, + ) + + created_team_api_key.additional_properties = d + return created_team_api_key + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/delete_template_tags_request.py b/packages/python-sdk/e2b/api/client/models/delete_template_tags_request.py new file mode 100644 index 0000000..2aa06ae --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/delete_template_tags_request.py @@ -0,0 +1,67 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="DeleteTemplateTagsRequest") + + +@_attrs_define +class DeleteTemplateTagsRequest: + """ + Attributes: + name (str): Name of the template + tags (list[str]): Tags to delete + """ + + name: str + tags: list[str] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + "tags": tags, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + tags = cast(list[str], d.pop("tags")) + + delete_template_tags_request = cls( + name=name, + tags=tags, + ) + + delete_template_tags_request.additional_properties = d + return delete_template_tags_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/disk_metrics.py b/packages/python-sdk/e2b/api/client/models/disk_metrics.py new file mode 100644 index 0000000..6d2b75c --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/disk_metrics.py @@ -0,0 +1,91 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="DiskMetrics") + + +@_attrs_define +class DiskMetrics: + """ + Attributes: + device (str): Device name + filesystem_type (str): Filesystem type (e.g., ext4, xfs) + mount_point (str): Mount point of the disk + total_bytes (int): Total space in bytes + used_bytes (int): Used space in bytes + """ + + device: str + filesystem_type: str + mount_point: str + total_bytes: int + used_bytes: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + device = self.device + + filesystem_type = self.filesystem_type + + mount_point = self.mount_point + + total_bytes = self.total_bytes + + used_bytes = self.used_bytes + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "device": device, + "filesystemType": filesystem_type, + "mountPoint": mount_point, + "totalBytes": total_bytes, + "usedBytes": used_bytes, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + device = d.pop("device") + + filesystem_type = d.pop("filesystemType") + + mount_point = d.pop("mountPoint") + + total_bytes = d.pop("totalBytes") + + used_bytes = d.pop("usedBytes") + + disk_metrics = cls( + device=device, + filesystem_type=filesystem_type, + mount_point=mount_point, + total_bytes=total_bytes, + used_bytes=used_bytes, + ) + + disk_metrics.additional_properties = d + return disk_metrics + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/error.py b/packages/python-sdk/e2b/api/client/models/error.py new file mode 100644 index 0000000..362b436 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/error.py @@ -0,0 +1,67 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="Error") + + +@_attrs_define +class Error: + """ + Attributes: + code (int): Error code + message (str): Error + """ + + code: int + message: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + code = self.code + + message = self.message + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "code": code, + "message": message, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + code = d.pop("code") + + message = d.pop("message") + + error = cls( + code=code, + message=message, + ) + + error.additional_properties = d + return error + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/gcp_registry.py b/packages/python-sdk/e2b/api/client/models/gcp_registry.py new file mode 100644 index 0000000..01b6ded --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/gcp_registry.py @@ -0,0 +1,69 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.gcp_registry_type import GCPRegistryType + +T = TypeVar("T", bound="GCPRegistry") + + +@_attrs_define +class GCPRegistry: + """ + Attributes: + service_account_json (str): Service Account JSON for GCP authentication + type_ (GCPRegistryType): Type of registry authentication + """ + + service_account_json: str + type_: GCPRegistryType + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + service_account_json = self.service_account_json + + type_ = self.type_.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "serviceAccountJson": service_account_json, + "type": type_, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + service_account_json = d.pop("serviceAccountJson") + + type_ = GCPRegistryType(d.pop("type")) + + gcp_registry = cls( + service_account_json=service_account_json, + type_=type_, + ) + + gcp_registry.additional_properties = d + return gcp_registry + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/gcp_registry_type.py b/packages/python-sdk/e2b/api/client/models/gcp_registry_type.py new file mode 100644 index 0000000..e9c8aa5 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/gcp_registry_type.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class GCPRegistryType(str, Enum): + GCP = "gcp" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/api/client/models/general_registry.py b/packages/python-sdk/e2b/api/client/models/general_registry.py new file mode 100644 index 0000000..38bc179 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/general_registry.py @@ -0,0 +1,77 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.general_registry_type import GeneralRegistryType + +T = TypeVar("T", bound="GeneralRegistry") + + +@_attrs_define +class GeneralRegistry: + """ + Attributes: + password (str): Password to use for the registry + type_ (GeneralRegistryType): Type of registry authentication + username (str): Username to use for the registry + """ + + password: str + type_: GeneralRegistryType + username: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + password = self.password + + type_ = self.type_.value + + username = self.username + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "password": password, + "type": type_, + "username": username, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + password = d.pop("password") + + type_ = GeneralRegistryType(d.pop("type")) + + username = d.pop("username") + + general_registry = cls( + password=password, + type_=type_, + username=username, + ) + + general_registry.additional_properties = d + return general_registry + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/general_registry_type.py b/packages/python-sdk/e2b/api/client/models/general_registry_type.py new file mode 100644 index 0000000..d89e784 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/general_registry_type.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class GeneralRegistryType(str, Enum): + REGISTRY = "registry" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/api/client/models/identifier_masking_details.py b/packages/python-sdk/e2b/api/client/models/identifier_masking_details.py new file mode 100644 index 0000000..60c0bde --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/identifier_masking_details.py @@ -0,0 +1,83 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="IdentifierMaskingDetails") + + +@_attrs_define +class IdentifierMaskingDetails: + """ + Attributes: + masked_value_prefix (str): Prefix used in masked version of the token or key + masked_value_suffix (str): Suffix used in masked version of the token or key + prefix (str): Prefix that identifies the token or key type + value_length (int): Length of the token or key + """ + + masked_value_prefix: str + masked_value_suffix: str + prefix: str + value_length: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + masked_value_prefix = self.masked_value_prefix + + masked_value_suffix = self.masked_value_suffix + + prefix = self.prefix + + value_length = self.value_length + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "maskedValuePrefix": masked_value_prefix, + "maskedValueSuffix": masked_value_suffix, + "prefix": prefix, + "valueLength": value_length, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + masked_value_prefix = d.pop("maskedValuePrefix") + + masked_value_suffix = d.pop("maskedValueSuffix") + + prefix = d.pop("prefix") + + value_length = d.pop("valueLength") + + identifier_masking_details = cls( + masked_value_prefix=masked_value_prefix, + masked_value_suffix=masked_value_suffix, + prefix=prefix, + value_length=value_length, + ) + + identifier_masking_details.additional_properties = d + return identifier_masking_details + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/listed_sandbox.py b/packages/python-sdk/e2b/api/client/models/listed_sandbox.py new file mode 100644 index 0000000..3cc572b --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/listed_sandbox.py @@ -0,0 +1,179 @@ +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.sandbox_state import SandboxState +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.sandbox_volume_mount import SandboxVolumeMount + + +T = TypeVar("T", bound="ListedSandbox") + + +@_attrs_define +class ListedSandbox: + """ + Attributes: + client_id (str): Identifier of the client + cpu_count (int): CPU cores for the sandbox + disk_size_mb (int): Disk size for the sandbox in MiB + end_at (datetime.datetime): Time when the sandbox will expire + envd_version (str): Version of the envd running in the sandbox + memory_mb (int): Memory for the sandbox in MiB + sandbox_id (str): Identifier of the sandbox + started_at (datetime.datetime): Time when the sandbox was started + state (SandboxState): State of the sandbox + template_id (str): Identifier of the template from which is the sandbox created + alias (Union[Unset, str]): Alias of the template + metadata (Union[Unset, Any]): + volume_mounts (Union[Unset, list['SandboxVolumeMount']]): + """ + + client_id: str + cpu_count: int + disk_size_mb: int + end_at: datetime.datetime + envd_version: str + memory_mb: int + sandbox_id: str + started_at: datetime.datetime + state: SandboxState + template_id: str + alias: Union[Unset, str] = UNSET + metadata: Union[Unset, Any] = UNSET + volume_mounts: Union[Unset, list["SandboxVolumeMount"]] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + client_id = self.client_id + + cpu_count = self.cpu_count + + disk_size_mb = self.disk_size_mb + + end_at = self.end_at.isoformat() + + envd_version = self.envd_version + + memory_mb = self.memory_mb + + sandbox_id = self.sandbox_id + + started_at = self.started_at.isoformat() + + state = self.state.value + + template_id = self.template_id + + alias = self.alias + + metadata = self.metadata + + volume_mounts: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.volume_mounts, Unset): + volume_mounts = [] + for volume_mounts_item_data in self.volume_mounts: + volume_mounts_item = volume_mounts_item_data.to_dict() + volume_mounts.append(volume_mounts_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "clientID": client_id, + "cpuCount": cpu_count, + "diskSizeMB": disk_size_mb, + "endAt": end_at, + "envdVersion": envd_version, + "memoryMB": memory_mb, + "sandboxID": sandbox_id, + "startedAt": started_at, + "state": state, + "templateID": template_id, + } + ) + if alias is not UNSET: + field_dict["alias"] = alias + if metadata is not UNSET: + field_dict["metadata"] = metadata + if volume_mounts is not UNSET: + field_dict["volumeMounts"] = volume_mounts + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_volume_mount import SandboxVolumeMount + + d = dict(src_dict) + client_id = d.pop("clientID") + + cpu_count = d.pop("cpuCount") + + disk_size_mb = d.pop("diskSizeMB") + + end_at = isoparse(d.pop("endAt")) + + envd_version = d.pop("envdVersion") + + memory_mb = d.pop("memoryMB") + + sandbox_id = d.pop("sandboxID") + + started_at = isoparse(d.pop("startedAt")) + + state = SandboxState(d.pop("state")) + + template_id = d.pop("templateID") + + alias = d.pop("alias", UNSET) + + metadata = d.pop("metadata", UNSET) + + volume_mounts = [] + _volume_mounts = d.pop("volumeMounts", UNSET) + for volume_mounts_item_data in _volume_mounts or []: + volume_mounts_item = SandboxVolumeMount.from_dict(volume_mounts_item_data) + + volume_mounts.append(volume_mounts_item) + + listed_sandbox = cls( + client_id=client_id, + cpu_count=cpu_count, + disk_size_mb=disk_size_mb, + end_at=end_at, + envd_version=envd_version, + memory_mb=memory_mb, + sandbox_id=sandbox_id, + started_at=started_at, + state=state, + template_id=template_id, + alias=alias, + metadata=metadata, + volume_mounts=volume_mounts, + ) + + listed_sandbox.additional_properties = d + return listed_sandbox + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/log_level.py b/packages/python-sdk/e2b/api/client/models/log_level.py new file mode 100644 index 0000000..bb5bcd9 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/log_level.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class LogLevel(str, Enum): + DEBUG = "debug" + ERROR = "error" + INFO = "info" + WARN = "warn" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/api/client/models/logs_direction.py b/packages/python-sdk/e2b/api/client/models/logs_direction.py new file mode 100644 index 0000000..3aa9bbd --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/logs_direction.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class LogsDirection(str, Enum): + BACKWARD = "backward" + FORWARD = "forward" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/api/client/models/logs_source.py b/packages/python-sdk/e2b/api/client/models/logs_source.py new file mode 100644 index 0000000..f27a641 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/logs_source.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class LogsSource(str, Enum): + PERSISTENT = "persistent" + TEMPORARY = "temporary" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/api/client/models/machine_info.py b/packages/python-sdk/e2b/api/client/models/machine_info.py new file mode 100644 index 0000000..cd63ef0 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/machine_info.py @@ -0,0 +1,83 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="MachineInfo") + + +@_attrs_define +class MachineInfo: + """ + Attributes: + cpu_architecture (str): CPU architecture of the node + cpu_family (str): CPU family of the node + cpu_model (str): CPU model of the node + cpu_model_name (str): CPU model name of the node + """ + + cpu_architecture: str + cpu_family: str + cpu_model: str + cpu_model_name: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + cpu_architecture = self.cpu_architecture + + cpu_family = self.cpu_family + + cpu_model = self.cpu_model + + cpu_model_name = self.cpu_model_name + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "cpuArchitecture": cpu_architecture, + "cpuFamily": cpu_family, + "cpuModel": cpu_model, + "cpuModelName": cpu_model_name, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + cpu_architecture = d.pop("cpuArchitecture") + + cpu_family = d.pop("cpuFamily") + + cpu_model = d.pop("cpuModel") + + cpu_model_name = d.pop("cpuModelName") + + machine_info = cls( + cpu_architecture=cpu_architecture, + cpu_family=cpu_family, + cpu_model=cpu_model, + cpu_model_name=cpu_model_name, + ) + + machine_info.additional_properties = d + return machine_info + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/max_team_metric.py b/packages/python-sdk/e2b/api/client/models/max_team_metric.py new file mode 100644 index 0000000..4a9c3e5 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/max_team_metric.py @@ -0,0 +1,78 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +T = TypeVar("T", bound="MaxTeamMetric") + + +@_attrs_define +class MaxTeamMetric: + """Team metric with timestamp + + Attributes: + timestamp (datetime.datetime): Timestamp of the metric entry + timestamp_unix (int): Timestamp of the metric entry in Unix time (seconds since epoch) + value (float): The maximum value of the requested metric in the given interval + """ + + timestamp: datetime.datetime + timestamp_unix: int + value: float + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + timestamp = self.timestamp.isoformat() + + timestamp_unix = self.timestamp_unix + + value = self.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "timestamp": timestamp, + "timestampUnix": timestamp_unix, + "value": value, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + timestamp = isoparse(d.pop("timestamp")) + + timestamp_unix = d.pop("timestampUnix") + + value = d.pop("value") + + max_team_metric = cls( + timestamp=timestamp, + timestamp_unix=timestamp_unix, + value=value, + ) + + max_team_metric.additional_properties = d + return max_team_metric + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/mcp_type_0.py b/packages/python-sdk/e2b/api/client/models/mcp_type_0.py new file mode 100644 index 0000000..a58d6ea --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/mcp_type_0.py @@ -0,0 +1,44 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="McpType0") + + +@_attrs_define +class McpType0: + """MCP configuration for the sandbox""" + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + mcp_type_0 = cls() + + mcp_type_0.additional_properties = d + return mcp_type_0 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/new_access_token.py b/packages/python-sdk/e2b/api/client/models/new_access_token.py new file mode 100644 index 0000000..642dac8 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/new_access_token.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="NewAccessToken") + + +@_attrs_define +class NewAccessToken: + """ + Attributes: + name (str): Name of the access token + """ + + name: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + new_access_token = cls( + name=name, + ) + + new_access_token.additional_properties = d + return new_access_token + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/new_sandbox.py b/packages/python-sdk/e2b/api/client/models/new_sandbox.py new file mode 100644 index 0000000..d09b636 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/new_sandbox.py @@ -0,0 +1,224 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.mcp_type_0 import McpType0 + from ..models.sandbox_auto_resume_config import SandboxAutoResumeConfig + from ..models.sandbox_network_config import SandboxNetworkConfig + from ..models.sandbox_volume_mount import SandboxVolumeMount + + +T = TypeVar("T", bound="NewSandbox") + + +@_attrs_define +class NewSandbox: + """ + Attributes: + template_id (str): Identifier of the required template + allow_internet_access (Union[Unset, bool]): Allow sandbox to access the internet. When set to false, it behaves + the same as specifying denyOut to 0.0.0.0/0 in the network config. + auto_pause (Union[Unset, bool]): Automatically pauses the sandbox after the timeout Default: False. + auto_pause_memory (Union[Unset, bool]): Controls the snapshot kind taken when the sandbox auto-pauses on timeout + (only relevant when autoPause is true). When false, the auto-pause drops the in-memory state and persists only + the filesystem (a filesystem-only snapshot); resuming it cold-boots (reboots) the sandbox from disk. Such a + snapshot cannot be auto-resumed by traffic and must be resumed explicitly, so it cannot be combined with + autoResume. Defaults to true (full memory snapshot). Default: True. + auto_resume (Union[Unset, SandboxAutoResumeConfig]): Auto-resume configuration for paused sandboxes. + env_vars (Union[Unset, Any]): + mcp (Union['McpType0', None, Unset]): MCP configuration for the sandbox + metadata (Union[Unset, Any]): + network (Union[Unset, SandboxNetworkConfig]): + secure (Union[Unset, bool]): Secure all system communication with sandbox + timeout (Union[Unset, int]): Time to live for the sandbox in seconds. Default: 15. + volume_mounts (Union[Unset, list['SandboxVolumeMount']]): + """ + + template_id: str + allow_internet_access: Union[Unset, bool] = UNSET + auto_pause: Union[Unset, bool] = False + auto_pause_memory: Union[Unset, bool] = True + auto_resume: Union[Unset, "SandboxAutoResumeConfig"] = UNSET + env_vars: Union[Unset, Any] = UNSET + mcp: Union["McpType0", None, Unset] = UNSET + metadata: Union[Unset, Any] = UNSET + network: Union[Unset, "SandboxNetworkConfig"] = UNSET + secure: Union[Unset, bool] = UNSET + timeout: Union[Unset, int] = 15 + volume_mounts: Union[Unset, list["SandboxVolumeMount"]] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.mcp_type_0 import McpType0 + + template_id = self.template_id + + allow_internet_access = self.allow_internet_access + + auto_pause = self.auto_pause + + auto_pause_memory = self.auto_pause_memory + + auto_resume: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.auto_resume, Unset): + auto_resume = self.auto_resume.to_dict() + + env_vars = self.env_vars + + mcp: Union[None, Unset, dict[str, Any]] + if isinstance(self.mcp, Unset): + mcp = UNSET + elif isinstance(self.mcp, McpType0): + mcp = self.mcp.to_dict() + else: + mcp = self.mcp + + metadata = self.metadata + + network: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.network, Unset): + network = self.network.to_dict() + + secure = self.secure + + timeout = self.timeout + + volume_mounts: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.volume_mounts, Unset): + volume_mounts = [] + for volume_mounts_item_data in self.volume_mounts: + volume_mounts_item = volume_mounts_item_data.to_dict() + volume_mounts.append(volume_mounts_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "templateID": template_id, + } + ) + if allow_internet_access is not UNSET: + field_dict["allow_internet_access"] = allow_internet_access + if auto_pause is not UNSET: + field_dict["autoPause"] = auto_pause + if auto_pause_memory is not UNSET: + field_dict["autoPauseMemory"] = auto_pause_memory + if auto_resume is not UNSET: + field_dict["autoResume"] = auto_resume + if env_vars is not UNSET: + field_dict["envVars"] = env_vars + if mcp is not UNSET: + field_dict["mcp"] = mcp + if metadata is not UNSET: + field_dict["metadata"] = metadata + if network is not UNSET: + field_dict["network"] = network + if secure is not UNSET: + field_dict["secure"] = secure + if timeout is not UNSET: + field_dict["timeout"] = timeout + if volume_mounts is not UNSET: + field_dict["volumeMounts"] = volume_mounts + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.mcp_type_0 import McpType0 + from ..models.sandbox_auto_resume_config import SandboxAutoResumeConfig + from ..models.sandbox_network_config import SandboxNetworkConfig + from ..models.sandbox_volume_mount import SandboxVolumeMount + + d = dict(src_dict) + template_id = d.pop("templateID") + + allow_internet_access = d.pop("allow_internet_access", UNSET) + + auto_pause = d.pop("autoPause", UNSET) + + auto_pause_memory = d.pop("autoPauseMemory", UNSET) + + _auto_resume = d.pop("autoResume", UNSET) + auto_resume: Union[Unset, SandboxAutoResumeConfig] + if isinstance(_auto_resume, Unset): + auto_resume = UNSET + else: + auto_resume = SandboxAutoResumeConfig.from_dict(_auto_resume) + + env_vars = d.pop("envVars", UNSET) + + def _parse_mcp(data: object) -> Union["McpType0", None, Unset]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_mcp_type_0 = McpType0.from_dict(data) + + return componentsschemas_mcp_type_0 + except: # noqa: E722 + pass + return cast(Union["McpType0", None, Unset], data) + + mcp = _parse_mcp(d.pop("mcp", UNSET)) + + metadata = d.pop("metadata", UNSET) + + _network = d.pop("network", UNSET) + network: Union[Unset, SandboxNetworkConfig] + if isinstance(_network, Unset): + network = UNSET + else: + network = SandboxNetworkConfig.from_dict(_network) + + secure = d.pop("secure", UNSET) + + timeout = d.pop("timeout", UNSET) + + volume_mounts = [] + _volume_mounts = d.pop("volumeMounts", UNSET) + for volume_mounts_item_data in _volume_mounts or []: + volume_mounts_item = SandboxVolumeMount.from_dict(volume_mounts_item_data) + + volume_mounts.append(volume_mounts_item) + + new_sandbox = cls( + template_id=template_id, + allow_internet_access=allow_internet_access, + auto_pause=auto_pause, + auto_pause_memory=auto_pause_memory, + auto_resume=auto_resume, + env_vars=env_vars, + mcp=mcp, + metadata=metadata, + network=network, + secure=secure, + timeout=timeout, + volume_mounts=volume_mounts, + ) + + new_sandbox.additional_properties = d + return new_sandbox + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/new_team_api_key.py b/packages/python-sdk/e2b/api/client/models/new_team_api_key.py new file mode 100644 index 0000000..2fac8a6 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/new_team_api_key.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="NewTeamAPIKey") + + +@_attrs_define +class NewTeamAPIKey: + """ + Attributes: + name (str): Name of the API key + """ + + name: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + new_team_api_key = cls( + name=name, + ) + + new_team_api_key.additional_properties = d + return new_team_api_key + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/new_volume.py b/packages/python-sdk/e2b/api/client/models/new_volume.py new file mode 100644 index 0000000..02dc450 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/new_volume.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="NewVolume") + + +@_attrs_define +class NewVolume: + """ + Attributes: + name (str): Name of the volume + """ + + name: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + new_volume = cls( + name=name, + ) + + new_volume.additional_properties = d + return new_volume + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/node.py b/packages/python-sdk/e2b/api/client/models/node.py new file mode 100644 index 0000000..f7d2a47 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/node.py @@ -0,0 +1,160 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.node_status import NodeStatus + +if TYPE_CHECKING: + from ..models.machine_info import MachineInfo + from ..models.node_metrics import NodeMetrics + + +T = TypeVar("T", bound="Node") + + +@_attrs_define +class Node: + """ + Attributes: + cluster_id (str): Identifier of the cluster + commit (str): Commit of the orchestrator + create_fails (int): Number of sandbox create fails + create_successes (int): Number of sandbox create successes + id (str): Identifier of the node + machine_info (MachineInfo): + metrics (NodeMetrics): Node metrics + sandbox_count (int): Number of sandboxes running on the node + sandbox_starting_count (int): Number of starting Sandboxes + service_instance_id (str): Service instance identifier of the node + status (NodeStatus): Status of the node. + - draining: the node is bound to be shut down. It will not accept new sandboxes and will stop once all existing + sandboxes are done. + - standby: the node is not actively used, but it can return to ready and continue serving traffic. + version (str): Version of the orchestrator + """ + + cluster_id: str + commit: str + create_fails: int + create_successes: int + id: str + machine_info: "MachineInfo" + metrics: "NodeMetrics" + sandbox_count: int + sandbox_starting_count: int + service_instance_id: str + status: NodeStatus + version: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + cluster_id = self.cluster_id + + commit = self.commit + + create_fails = self.create_fails + + create_successes = self.create_successes + + id = self.id + + machine_info = self.machine_info.to_dict() + + metrics = self.metrics.to_dict() + + sandbox_count = self.sandbox_count + + sandbox_starting_count = self.sandbox_starting_count + + service_instance_id = self.service_instance_id + + status = self.status.value + + version = self.version + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "clusterID": cluster_id, + "commit": commit, + "createFails": create_fails, + "createSuccesses": create_successes, + "id": id, + "machineInfo": machine_info, + "metrics": metrics, + "sandboxCount": sandbox_count, + "sandboxStartingCount": sandbox_starting_count, + "serviceInstanceID": service_instance_id, + "status": status, + "version": version, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.machine_info import MachineInfo + from ..models.node_metrics import NodeMetrics + + d = dict(src_dict) + cluster_id = d.pop("clusterID") + + commit = d.pop("commit") + + create_fails = d.pop("createFails") + + create_successes = d.pop("createSuccesses") + + id = d.pop("id") + + machine_info = MachineInfo.from_dict(d.pop("machineInfo")) + + metrics = NodeMetrics.from_dict(d.pop("metrics")) + + sandbox_count = d.pop("sandboxCount") + + sandbox_starting_count = d.pop("sandboxStartingCount") + + service_instance_id = d.pop("serviceInstanceID") + + status = NodeStatus(d.pop("status")) + + version = d.pop("version") + + node = cls( + cluster_id=cluster_id, + commit=commit, + create_fails=create_fails, + create_successes=create_successes, + id=id, + machine_info=machine_info, + metrics=metrics, + sandbox_count=sandbox_count, + sandbox_starting_count=sandbox_starting_count, + service_instance_id=service_instance_id, + status=status, + version=version, + ) + + node.additional_properties = d + return node + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/node_detail.py b/packages/python-sdk/e2b/api/client/models/node_detail.py new file mode 100644 index 0000000..a5c1f61 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/node_detail.py @@ -0,0 +1,160 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.node_status import NodeStatus + +if TYPE_CHECKING: + from ..models.machine_info import MachineInfo + from ..models.node_metrics import NodeMetrics + + +T = TypeVar("T", bound="NodeDetail") + + +@_attrs_define +class NodeDetail: + """ + Attributes: + cached_builds (list[str]): List of cached builds id on the node + cluster_id (str): Identifier of the cluster + commit (str): Commit of the orchestrator + create_fails (int): Number of sandbox create fails + create_successes (int): Number of sandbox create successes + id (str): Identifier of the node + machine_info (MachineInfo): + metrics (NodeMetrics): Node metrics + sandbox_count (int): Number of sandboxes running on the node + service_instance_id (str): Service instance identifier of the node + status (NodeStatus): Status of the node. + - draining: the node is bound to be shut down. It will not accept new sandboxes and will stop once all existing + sandboxes are done. + - standby: the node is not actively used, but it can return to ready and continue serving traffic. + version (str): Version of the orchestrator + """ + + cached_builds: list[str] + cluster_id: str + commit: str + create_fails: int + create_successes: int + id: str + machine_info: "MachineInfo" + metrics: "NodeMetrics" + sandbox_count: int + service_instance_id: str + status: NodeStatus + version: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + cached_builds = self.cached_builds + + cluster_id = self.cluster_id + + commit = self.commit + + create_fails = self.create_fails + + create_successes = self.create_successes + + id = self.id + + machine_info = self.machine_info.to_dict() + + metrics = self.metrics.to_dict() + + sandbox_count = self.sandbox_count + + service_instance_id = self.service_instance_id + + status = self.status.value + + version = self.version + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "cachedBuilds": cached_builds, + "clusterID": cluster_id, + "commit": commit, + "createFails": create_fails, + "createSuccesses": create_successes, + "id": id, + "machineInfo": machine_info, + "metrics": metrics, + "sandboxCount": sandbox_count, + "serviceInstanceID": service_instance_id, + "status": status, + "version": version, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.machine_info import MachineInfo + from ..models.node_metrics import NodeMetrics + + d = dict(src_dict) + cached_builds = cast(list[str], d.pop("cachedBuilds")) + + cluster_id = d.pop("clusterID") + + commit = d.pop("commit") + + create_fails = d.pop("createFails") + + create_successes = d.pop("createSuccesses") + + id = d.pop("id") + + machine_info = MachineInfo.from_dict(d.pop("machineInfo")) + + metrics = NodeMetrics.from_dict(d.pop("metrics")) + + sandbox_count = d.pop("sandboxCount") + + service_instance_id = d.pop("serviceInstanceID") + + status = NodeStatus(d.pop("status")) + + version = d.pop("version") + + node_detail = cls( + cached_builds=cached_builds, + cluster_id=cluster_id, + commit=commit, + create_fails=create_fails, + create_successes=create_successes, + id=id, + machine_info=machine_info, + metrics=metrics, + sandbox_count=sandbox_count, + service_instance_id=service_instance_id, + status=status, + version=version, + ) + + node_detail.additional_properties = d + return node_detail + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/node_metrics.py b/packages/python-sdk/e2b/api/client/models/node_metrics.py new file mode 100644 index 0000000..129d74f --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/node_metrics.py @@ -0,0 +1,122 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.disk_metrics import DiskMetrics + + +T = TypeVar("T", bound="NodeMetrics") + + +@_attrs_define +class NodeMetrics: + """Node metrics + + Attributes: + allocated_cpu (int): Number of allocated CPU cores + allocated_memory_bytes (int): Amount of allocated memory in bytes + cpu_count (int): Total number of CPU cores on the node + cpu_percent (int): Node CPU usage percentage + disks (list['DiskMetrics']): Detailed metrics for each disk/mount point + memory_total_bytes (int): Total node memory in bytes + memory_used_bytes (int): Node memory used in bytes + """ + + allocated_cpu: int + allocated_memory_bytes: int + cpu_count: int + cpu_percent: int + disks: list["DiskMetrics"] + memory_total_bytes: int + memory_used_bytes: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + allocated_cpu = self.allocated_cpu + + allocated_memory_bytes = self.allocated_memory_bytes + + cpu_count = self.cpu_count + + cpu_percent = self.cpu_percent + + disks = [] + for disks_item_data in self.disks: + disks_item = disks_item_data.to_dict() + disks.append(disks_item) + + memory_total_bytes = self.memory_total_bytes + + memory_used_bytes = self.memory_used_bytes + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "allocatedCPU": allocated_cpu, + "allocatedMemoryBytes": allocated_memory_bytes, + "cpuCount": cpu_count, + "cpuPercent": cpu_percent, + "disks": disks, + "memoryTotalBytes": memory_total_bytes, + "memoryUsedBytes": memory_used_bytes, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.disk_metrics import DiskMetrics + + d = dict(src_dict) + allocated_cpu = d.pop("allocatedCPU") + + allocated_memory_bytes = d.pop("allocatedMemoryBytes") + + cpu_count = d.pop("cpuCount") + + cpu_percent = d.pop("cpuPercent") + + disks = [] + _disks = d.pop("disks") + for disks_item_data in _disks: + disks_item = DiskMetrics.from_dict(disks_item_data) + + disks.append(disks_item) + + memory_total_bytes = d.pop("memoryTotalBytes") + + memory_used_bytes = d.pop("memoryUsedBytes") + + node_metrics = cls( + allocated_cpu=allocated_cpu, + allocated_memory_bytes=allocated_memory_bytes, + cpu_count=cpu_count, + cpu_percent=cpu_percent, + disks=disks, + memory_total_bytes=memory_total_bytes, + memory_used_bytes=memory_used_bytes, + ) + + node_metrics.additional_properties = d + return node_metrics + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/node_status.py b/packages/python-sdk/e2b/api/client/models/node_status.py new file mode 100644 index 0000000..9f7aa1f --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/node_status.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class NodeStatus(str, Enum): + CONNECTING = "connecting" + DRAINING = "draining" + READY = "ready" + STANDBY = "standby" + UNHEALTHY = "unhealthy" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/api/client/models/node_status_change.py b/packages/python-sdk/e2b/api/client/models/node_status_change.py new file mode 100644 index 0000000..06d09a1 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/node_status_change.py @@ -0,0 +1,82 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.node_status import NodeStatus +from ..types import UNSET, Unset + +T = TypeVar("T", bound="NodeStatusChange") + + +@_attrs_define +class NodeStatusChange: + """ + Attributes: + status (NodeStatus): Status of the node. + - draining: the node is bound to be shut down. It will not accept new sandboxes and will stop once all existing + sandboxes are done. + - standby: the node is not actively used, but it can return to ready and continue serving traffic. + cluster_id (Union[Unset, UUID]): Identifier of the cluster + """ + + status: NodeStatus + cluster_id: Union[Unset, UUID] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + status = self.status.value + + cluster_id: Union[Unset, str] = UNSET + if not isinstance(self.cluster_id, Unset): + cluster_id = str(self.cluster_id) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "status": status, + } + ) + if cluster_id is not UNSET: + field_dict["clusterID"] = cluster_id + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + status = NodeStatus(d.pop("status")) + + _cluster_id = d.pop("clusterID", UNSET) + cluster_id: Union[Unset, UUID] + if isinstance(_cluster_id, Unset): + cluster_id = UNSET + else: + cluster_id = UUID(_cluster_id) + + node_status_change = cls( + status=status, + cluster_id=cluster_id, + ) + + node_status_change.additional_properties = d + return node_status_change + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/post_sandboxes_sandbox_id_refreshes_body.py b/packages/python-sdk/e2b/api/client/models/post_sandboxes_sandbox_id_refreshes_body.py new file mode 100644 index 0000000..5a35d26 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/post_sandboxes_sandbox_id_refreshes_body.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="PostSandboxesSandboxIDRefreshesBody") + + +@_attrs_define +class PostSandboxesSandboxIDRefreshesBody: + """ + Attributes: + duration (Union[Unset, int]): Duration for which the sandbox should be kept alive in seconds + """ + + duration: Union[Unset, int] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + duration = self.duration + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if duration is not UNSET: + field_dict["duration"] = duration + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + duration = d.pop("duration", UNSET) + + post_sandboxes_sandbox_id_refreshes_body = cls( + duration=duration, + ) + + post_sandboxes_sandbox_id_refreshes_body.additional_properties = d + return post_sandboxes_sandbox_id_refreshes_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/post_sandboxes_sandbox_id_snapshots_body.py b/packages/python-sdk/e2b/api/client/models/post_sandboxes_sandbox_id_snapshots_body.py new file mode 100644 index 0000000..680f5b5 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/post_sandboxes_sandbox_id_snapshots_body.py @@ -0,0 +1,60 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="PostSandboxesSandboxIDSnapshotsBody") + + +@_attrs_define +class PostSandboxesSandboxIDSnapshotsBody: + """ + Attributes: + name (Union[Unset, str]): Optional name for the snapshot template. If a snapshot template with this name already + exists, a new build will be assigned to the existing template instead of creating a new one. + """ + + name: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name", UNSET) + + post_sandboxes_sandbox_id_snapshots_body = cls( + name=name, + ) + + post_sandboxes_sandbox_id_snapshots_body.additional_properties = d + return post_sandboxes_sandbox_id_snapshots_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/post_sandboxes_sandbox_id_timeout_body.py b/packages/python-sdk/e2b/api/client/models/post_sandboxes_sandbox_id_timeout_body.py new file mode 100644 index 0000000..4d06c65 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/post_sandboxes_sandbox_id_timeout_body.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="PostSandboxesSandboxIDTimeoutBody") + + +@_attrs_define +class PostSandboxesSandboxIDTimeoutBody: + """ + Attributes: + timeout (int): Timeout in seconds from the current time after which the sandbox should expire + """ + + timeout: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + timeout = self.timeout + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "timeout": timeout, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + timeout = d.pop("timeout") + + post_sandboxes_sandbox_id_timeout_body = cls( + timeout=timeout, + ) + + post_sandboxes_sandbox_id_timeout_body.additional_properties = d + return post_sandboxes_sandbox_id_timeout_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/resumed_sandbox.py b/packages/python-sdk/e2b/api/client/models/resumed_sandbox.py new file mode 100644 index 0000000..d990dc8 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/resumed_sandbox.py @@ -0,0 +1,68 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ResumedSandbox") + + +@_attrs_define +class ResumedSandbox: + """ + Attributes: + auto_pause (Union[Unset, bool]): Automatically pauses the sandbox after the timeout + timeout (Union[Unset, int]): Time to live for the sandbox in seconds. Default: 15. + """ + + auto_pause: Union[Unset, bool] = UNSET + timeout: Union[Unset, int] = 15 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + auto_pause = self.auto_pause + + timeout = self.timeout + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if auto_pause is not UNSET: + field_dict["autoPause"] = auto_pause + if timeout is not UNSET: + field_dict["timeout"] = timeout + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + auto_pause = d.pop("autoPause", UNSET) + + timeout = d.pop("timeout", UNSET) + + resumed_sandbox = cls( + auto_pause=auto_pause, + timeout=timeout, + ) + + resumed_sandbox.additional_properties = d + return resumed_sandbox + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox.py b/packages/python-sdk/e2b/api/client/models/sandbox.py new file mode 100644 index 0000000..2eddc5f --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox.py @@ -0,0 +1,145 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="Sandbox") + + +@_attrs_define +class Sandbox: + """ + Attributes: + client_id (str): Identifier of the client + envd_version (str): Version of the envd running in the sandbox + sandbox_id (str): Identifier of the sandbox + template_id (str): Identifier of the template from which is the sandbox created + alias (Union[Unset, str]): Alias of the template + domain (Union[None, Unset, str]): Base domain where the sandbox traffic is accessible + envd_access_token (Union[Unset, str]): Access token used for envd communication + traffic_access_token (Union[None, Unset, str]): Token required for accessing sandbox via proxy. + """ + + client_id: str + envd_version: str + sandbox_id: str + template_id: str + alias: Union[Unset, str] = UNSET + domain: Union[None, Unset, str] = UNSET + envd_access_token: Union[Unset, str] = UNSET + traffic_access_token: Union[None, Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + client_id = self.client_id + + envd_version = self.envd_version + + sandbox_id = self.sandbox_id + + template_id = self.template_id + + alias = self.alias + + domain: Union[None, Unset, str] + if isinstance(self.domain, Unset): + domain = UNSET + else: + domain = self.domain + + envd_access_token = self.envd_access_token + + traffic_access_token: Union[None, Unset, str] + if isinstance(self.traffic_access_token, Unset): + traffic_access_token = UNSET + else: + traffic_access_token = self.traffic_access_token + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "clientID": client_id, + "envdVersion": envd_version, + "sandboxID": sandbox_id, + "templateID": template_id, + } + ) + if alias is not UNSET: + field_dict["alias"] = alias + if domain is not UNSET: + field_dict["domain"] = domain + if envd_access_token is not UNSET: + field_dict["envdAccessToken"] = envd_access_token + if traffic_access_token is not UNSET: + field_dict["trafficAccessToken"] = traffic_access_token + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + client_id = d.pop("clientID") + + envd_version = d.pop("envdVersion") + + sandbox_id = d.pop("sandboxID") + + template_id = d.pop("templateID") + + alias = d.pop("alias", UNSET) + + def _parse_domain(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + domain = _parse_domain(d.pop("domain", UNSET)) + + envd_access_token = d.pop("envdAccessToken", UNSET) + + def _parse_traffic_access_token(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + traffic_access_token = _parse_traffic_access_token( + d.pop("trafficAccessToken", UNSET) + ) + + sandbox = cls( + client_id=client_id, + envd_version=envd_version, + sandbox_id=sandbox_id, + template_id=template_id, + alias=alias, + domain=domain, + envd_access_token=envd_access_token, + traffic_access_token=traffic_access_token, + ) + + sandbox.additional_properties = d + return sandbox + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_auto_resume_config.py b/packages/python-sdk/e2b/api/client/models/sandbox_auto_resume_config.py new file mode 100644 index 0000000..c5db58b --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_auto_resume_config.py @@ -0,0 +1,60 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SandboxAutoResumeConfig") + + +@_attrs_define +class SandboxAutoResumeConfig: + """Auto-resume configuration for paused sandboxes. + + Attributes: + enabled (bool): Auto-resume enabled flag for paused sandboxes. Default false. + """ + + enabled: bool + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + enabled = self.enabled + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "enabled": enabled, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + enabled = d.pop("enabled") + + sandbox_auto_resume_config = cls( + enabled=enabled, + ) + + sandbox_auto_resume_config.additional_properties = d + return sandbox_auto_resume_config + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_detail.py b/packages/python-sdk/e2b/api/client/models/sandbox_detail.py new file mode 100644 index 0000000..1078d2b --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_detail.py @@ -0,0 +1,267 @@ +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.sandbox_state import SandboxState +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.sandbox_lifecycle import SandboxLifecycle + from ..models.sandbox_network_config import SandboxNetworkConfig + from ..models.sandbox_volume_mount import SandboxVolumeMount + + +T = TypeVar("T", bound="SandboxDetail") + + +@_attrs_define +class SandboxDetail: + """ + Attributes: + client_id (str): Identifier of the client + cpu_count (int): CPU cores for the sandbox + disk_size_mb (int): Disk size for the sandbox in MiB + end_at (datetime.datetime): Time when the sandbox will expire + envd_version (str): Version of the envd running in the sandbox + memory_mb (int): Memory for the sandbox in MiB + sandbox_id (str): Identifier of the sandbox + started_at (datetime.datetime): Time when the sandbox was started + state (SandboxState): State of the sandbox + template_id (str): Identifier of the template from which is the sandbox created + alias (Union[Unset, str]): Alias of the template + allow_internet_access (Union[None, Unset, bool]): Whether internet access was explicitly enabled or disabled for + the sandbox. Null means it was not explicitly set. + domain (Union[None, Unset, str]): Base domain where the sandbox traffic is accessible + envd_access_token (Union[Unset, str]): Access token used for envd communication + lifecycle (Union[Unset, SandboxLifecycle]): Sandbox lifecycle policy returned by sandbox info. + metadata (Union[Unset, Any]): + network (Union[Unset, SandboxNetworkConfig]): + volume_mounts (Union[Unset, list['SandboxVolumeMount']]): + """ + + client_id: str + cpu_count: int + disk_size_mb: int + end_at: datetime.datetime + envd_version: str + memory_mb: int + sandbox_id: str + started_at: datetime.datetime + state: SandboxState + template_id: str + alias: Union[Unset, str] = UNSET + allow_internet_access: Union[None, Unset, bool] = UNSET + domain: Union[None, Unset, str] = UNSET + envd_access_token: Union[Unset, str] = UNSET + lifecycle: Union[Unset, "SandboxLifecycle"] = UNSET + metadata: Union[Unset, Any] = UNSET + network: Union[Unset, "SandboxNetworkConfig"] = UNSET + volume_mounts: Union[Unset, list["SandboxVolumeMount"]] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + client_id = self.client_id + + cpu_count = self.cpu_count + + disk_size_mb = self.disk_size_mb + + end_at = self.end_at.isoformat() + + envd_version = self.envd_version + + memory_mb = self.memory_mb + + sandbox_id = self.sandbox_id + + started_at = self.started_at.isoformat() + + state = self.state.value + + template_id = self.template_id + + alias = self.alias + + allow_internet_access: Union[None, Unset, bool] + if isinstance(self.allow_internet_access, Unset): + allow_internet_access = UNSET + else: + allow_internet_access = self.allow_internet_access + + domain: Union[None, Unset, str] + if isinstance(self.domain, Unset): + domain = UNSET + else: + domain = self.domain + + envd_access_token = self.envd_access_token + + lifecycle: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.lifecycle, Unset): + lifecycle = self.lifecycle.to_dict() + + metadata = self.metadata + + network: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.network, Unset): + network = self.network.to_dict() + + volume_mounts: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.volume_mounts, Unset): + volume_mounts = [] + for volume_mounts_item_data in self.volume_mounts: + volume_mounts_item = volume_mounts_item_data.to_dict() + volume_mounts.append(volume_mounts_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "clientID": client_id, + "cpuCount": cpu_count, + "diskSizeMB": disk_size_mb, + "endAt": end_at, + "envdVersion": envd_version, + "memoryMB": memory_mb, + "sandboxID": sandbox_id, + "startedAt": started_at, + "state": state, + "templateID": template_id, + } + ) + if alias is not UNSET: + field_dict["alias"] = alias + if allow_internet_access is not UNSET: + field_dict["allowInternetAccess"] = allow_internet_access + if domain is not UNSET: + field_dict["domain"] = domain + if envd_access_token is not UNSET: + field_dict["envdAccessToken"] = envd_access_token + if lifecycle is not UNSET: + field_dict["lifecycle"] = lifecycle + if metadata is not UNSET: + field_dict["metadata"] = metadata + if network is not UNSET: + field_dict["network"] = network + if volume_mounts is not UNSET: + field_dict["volumeMounts"] = volume_mounts + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_lifecycle import SandboxLifecycle + from ..models.sandbox_network_config import SandboxNetworkConfig + from ..models.sandbox_volume_mount import SandboxVolumeMount + + d = dict(src_dict) + client_id = d.pop("clientID") + + cpu_count = d.pop("cpuCount") + + disk_size_mb = d.pop("diskSizeMB") + + end_at = isoparse(d.pop("endAt")) + + envd_version = d.pop("envdVersion") + + memory_mb = d.pop("memoryMB") + + sandbox_id = d.pop("sandboxID") + + started_at = isoparse(d.pop("startedAt")) + + state = SandboxState(d.pop("state")) + + template_id = d.pop("templateID") + + alias = d.pop("alias", UNSET) + + def _parse_allow_internet_access(data: object) -> Union[None, Unset, bool]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, bool], data) + + allow_internet_access = _parse_allow_internet_access( + d.pop("allowInternetAccess", UNSET) + ) + + def _parse_domain(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + domain = _parse_domain(d.pop("domain", UNSET)) + + envd_access_token = d.pop("envdAccessToken", UNSET) + + _lifecycle = d.pop("lifecycle", UNSET) + lifecycle: Union[Unset, SandboxLifecycle] + if isinstance(_lifecycle, Unset): + lifecycle = UNSET + else: + lifecycle = SandboxLifecycle.from_dict(_lifecycle) + + metadata = d.pop("metadata", UNSET) + + _network = d.pop("network", UNSET) + network: Union[Unset, SandboxNetworkConfig] + if isinstance(_network, Unset): + network = UNSET + else: + network = SandboxNetworkConfig.from_dict(_network) + + volume_mounts = [] + _volume_mounts = d.pop("volumeMounts", UNSET) + for volume_mounts_item_data in _volume_mounts or []: + volume_mounts_item = SandboxVolumeMount.from_dict(volume_mounts_item_data) + + volume_mounts.append(volume_mounts_item) + + sandbox_detail = cls( + client_id=client_id, + cpu_count=cpu_count, + disk_size_mb=disk_size_mb, + end_at=end_at, + envd_version=envd_version, + memory_mb=memory_mb, + sandbox_id=sandbox_id, + started_at=started_at, + state=state, + template_id=template_id, + alias=alias, + allow_internet_access=allow_internet_access, + domain=domain, + envd_access_token=envd_access_token, + lifecycle=lifecycle, + metadata=metadata, + network=network, + volume_mounts=volume_mounts, + ) + + sandbox_detail.additional_properties = d + return sandbox_detail + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_lifecycle.py b/packages/python-sdk/e2b/api/client/models/sandbox_lifecycle.py new file mode 100644 index 0000000..2912b73 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_lifecycle.py @@ -0,0 +1,70 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.sandbox_on_timeout import SandboxOnTimeout + +T = TypeVar("T", bound="SandboxLifecycle") + + +@_attrs_define +class SandboxLifecycle: + """Sandbox lifecycle policy returned by sandbox info. + + Attributes: + auto_resume (bool): Whether the sandbox can auto-resume. + on_timeout (SandboxOnTimeout): Action taken when the sandbox times out. + """ + + auto_resume: bool + on_timeout: SandboxOnTimeout + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + auto_resume = self.auto_resume + + on_timeout = self.on_timeout.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "autoResume": auto_resume, + "onTimeout": on_timeout, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + auto_resume = d.pop("autoResume") + + on_timeout = SandboxOnTimeout(d.pop("onTimeout")) + + sandbox_lifecycle = cls( + auto_resume=auto_resume, + on_timeout=on_timeout, + ) + + sandbox_lifecycle.additional_properties = d + return sandbox_lifecycle + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_log.py b/packages/python-sdk/e2b/api/client/models/sandbox_log.py new file mode 100644 index 0000000..65b9144 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_log.py @@ -0,0 +1,70 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +T = TypeVar("T", bound="SandboxLog") + + +@_attrs_define +class SandboxLog: + """Log entry with timestamp and line + + Attributes: + line (str): Log line content + timestamp (datetime.datetime): Timestamp of the log entry + """ + + line: str + timestamp: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + line = self.line + + timestamp = self.timestamp.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "line": line, + "timestamp": timestamp, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + line = d.pop("line") + + timestamp = isoparse(d.pop("timestamp")) + + sandbox_log = cls( + line=line, + timestamp=timestamp, + ) + + sandbox_log.additional_properties = d + return sandbox_log + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_log_entry.py b/packages/python-sdk/e2b/api/client/models/sandbox_log_entry.py new file mode 100644 index 0000000..bcbbe7d --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_log_entry.py @@ -0,0 +1,93 @@ +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.log_level import LogLevel + +if TYPE_CHECKING: + from ..models.sandbox_log_entry_fields import SandboxLogEntryFields + + +T = TypeVar("T", bound="SandboxLogEntry") + + +@_attrs_define +class SandboxLogEntry: + """ + Attributes: + fields (SandboxLogEntryFields): + level (LogLevel): State of the sandbox + message (str): Log message content + timestamp (datetime.datetime): Timestamp of the log entry + """ + + fields: "SandboxLogEntryFields" + level: LogLevel + message: str + timestamp: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + fields = self.fields.to_dict() + + level = self.level.value + + message = self.message + + timestamp = self.timestamp.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "fields": fields, + "level": level, + "message": message, + "timestamp": timestamp, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_log_entry_fields import SandboxLogEntryFields + + d = dict(src_dict) + fields = SandboxLogEntryFields.from_dict(d.pop("fields")) + + level = LogLevel(d.pop("level")) + + message = d.pop("message") + + timestamp = isoparse(d.pop("timestamp")) + + sandbox_log_entry = cls( + fields=fields, + level=level, + message=message, + timestamp=timestamp, + ) + + sandbox_log_entry.additional_properties = d + return sandbox_log_entry + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_log_entry_fields.py b/packages/python-sdk/e2b/api/client/models/sandbox_log_entry_fields.py new file mode 100644 index 0000000..6ac463d --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_log_entry_fields.py @@ -0,0 +1,44 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SandboxLogEntryFields") + + +@_attrs_define +class SandboxLogEntryFields: + """ """ + + additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + sandbox_log_entry_fields = cls() + + sandbox_log_entry_fields.additional_properties = d + return sandbox_log_entry_fields + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_logs.py b/packages/python-sdk/e2b/api/client/models/sandbox_logs.py new file mode 100644 index 0000000..39a1eb5 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_logs.py @@ -0,0 +1,91 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.sandbox_log import SandboxLog + from ..models.sandbox_log_entry import SandboxLogEntry + + +T = TypeVar("T", bound="SandboxLogs") + + +@_attrs_define +class SandboxLogs: + """ + Attributes: + log_entries (list['SandboxLogEntry']): Structured logs of the sandbox + logs (list['SandboxLog']): Logs of the sandbox + """ + + log_entries: list["SandboxLogEntry"] + logs: list["SandboxLog"] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + log_entries = [] + for log_entries_item_data in self.log_entries: + log_entries_item = log_entries_item_data.to_dict() + log_entries.append(log_entries_item) + + logs = [] + for logs_item_data in self.logs: + logs_item = logs_item_data.to_dict() + logs.append(logs_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "logEntries": log_entries, + "logs": logs, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_log import SandboxLog + from ..models.sandbox_log_entry import SandboxLogEntry + + d = dict(src_dict) + log_entries = [] + _log_entries = d.pop("logEntries") + for log_entries_item_data in _log_entries: + log_entries_item = SandboxLogEntry.from_dict(log_entries_item_data) + + log_entries.append(log_entries_item) + + logs = [] + _logs = d.pop("logs") + for logs_item_data in _logs: + logs_item = SandboxLog.from_dict(logs_item_data) + + logs.append(logs_item) + + sandbox_logs = cls( + log_entries=log_entries, + logs=logs, + ) + + sandbox_logs.additional_properties = d + return sandbox_logs + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_logs_v2_response.py b/packages/python-sdk/e2b/api/client/models/sandbox_logs_v2_response.py new file mode 100644 index 0000000..f7e2544 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_logs_v2_response.py @@ -0,0 +1,73 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.sandbox_log_entry import SandboxLogEntry + + +T = TypeVar("T", bound="SandboxLogsV2Response") + + +@_attrs_define +class SandboxLogsV2Response: + """ + Attributes: + logs (list['SandboxLogEntry']): Sandbox logs structured + """ + + logs: list["SandboxLogEntry"] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + logs = [] + for logs_item_data in self.logs: + logs_item = logs_item_data.to_dict() + logs.append(logs_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "logs": logs, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_log_entry import SandboxLogEntry + + d = dict(src_dict) + logs = [] + _logs = d.pop("logs") + for logs_item_data in _logs: + logs_item = SandboxLogEntry.from_dict(logs_item_data) + + logs.append(logs_item) + + sandbox_logs_v2_response = cls( + logs=logs, + ) + + sandbox_logs_v2_response.additional_properties = d + return sandbox_logs_v2_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_metric.py b/packages/python-sdk/e2b/api/client/models/sandbox_metric.py new file mode 100644 index 0000000..cbfb9ee --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_metric.py @@ -0,0 +1,126 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +T = TypeVar("T", bound="SandboxMetric") + + +@_attrs_define +class SandboxMetric: + """Metric entry with timestamp and line + + Attributes: + cpu_count (int): Number of CPU cores + cpu_used_pct (float): CPU usage percentage + disk_total (int): Total disk space in bytes + disk_used (int): Disk used in bytes + mem_cache (int): Cached memory (page cache) in bytes + mem_total (int): Total memory in bytes + mem_used (int): Memory used in bytes + timestamp (datetime.datetime): Timestamp of the metric entry + timestamp_unix (int): Timestamp of the metric entry in Unix time (seconds since epoch) + """ + + cpu_count: int + cpu_used_pct: float + disk_total: int + disk_used: int + mem_cache: int + mem_total: int + mem_used: int + timestamp: datetime.datetime + timestamp_unix: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + cpu_count = self.cpu_count + + cpu_used_pct = self.cpu_used_pct + + disk_total = self.disk_total + + disk_used = self.disk_used + + mem_cache = self.mem_cache + + mem_total = self.mem_total + + mem_used = self.mem_used + + timestamp = self.timestamp.isoformat() + + timestamp_unix = self.timestamp_unix + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "cpuCount": cpu_count, + "cpuUsedPct": cpu_used_pct, + "diskTotal": disk_total, + "diskUsed": disk_used, + "memCache": mem_cache, + "memTotal": mem_total, + "memUsed": mem_used, + "timestamp": timestamp, + "timestampUnix": timestamp_unix, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + cpu_count = d.pop("cpuCount") + + cpu_used_pct = d.pop("cpuUsedPct") + + disk_total = d.pop("diskTotal") + + disk_used = d.pop("diskUsed") + + mem_cache = d.pop("memCache") + + mem_total = d.pop("memTotal") + + mem_used = d.pop("memUsed") + + timestamp = isoparse(d.pop("timestamp")) + + timestamp_unix = d.pop("timestampUnix") + + sandbox_metric = cls( + cpu_count=cpu_count, + cpu_used_pct=cpu_used_pct, + disk_total=disk_total, + disk_used=disk_used, + mem_cache=mem_cache, + mem_total=mem_total, + mem_used=mem_used, + timestamp=timestamp, + timestamp_unix=timestamp_unix, + ) + + sandbox_metric.additional_properties = d + return sandbox_metric + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py new file mode 100644 index 0000000..c7a99c1 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_config.py @@ -0,0 +1,118 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.sandbox_network_config_rules import SandboxNetworkConfigRules + + +T = TypeVar("T", bound="SandboxNetworkConfig") + + +@_attrs_define +class SandboxNetworkConfig: + """ + Attributes: + allow_out (Union[Unset, list[str]]): List of allowed destinations for egress traffic. Each entry can be a CIDR + block (e.g. "8.8.8.8/32"), a bare IP address (e.g. "8.8.8.8"), or a domain name (e.g. "example.com", + "*.example.com"). Allowed entries always take precedence over denied entries. + allow_public_traffic (Union[Unset, bool]): Specify if the sandbox URLs should be accessible only with + authentication. Default: True. + deny_out (Union[Unset, list[str]]): List of denied CIDR blocks or IP addresses for egress traffic. Domain names + are not supported for deny rules. + mask_request_host (Union[Unset, str]): Specify host mask which will be used for all sandbox requests + rules (Union[Unset, SandboxNetworkConfigRules]): Per-domain transform rules applied to matching egress + HTTP/HTTPS requests. Keys are domains (e.g. "api.example.com", "example.com"). A domain listed here is not + automatically allowed - use allowOut to permit the traffic. + """ + + allow_out: Union[Unset, list[str]] = UNSET + allow_public_traffic: Union[Unset, bool] = True + deny_out: Union[Unset, list[str]] = UNSET + mask_request_host: Union[Unset, str] = UNSET + rules: Union[Unset, "SandboxNetworkConfigRules"] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + allow_out: Union[Unset, list[str]] = UNSET + if not isinstance(self.allow_out, Unset): + allow_out = self.allow_out + + allow_public_traffic = self.allow_public_traffic + + deny_out: Union[Unset, list[str]] = UNSET + if not isinstance(self.deny_out, Unset): + deny_out = self.deny_out + + mask_request_host = self.mask_request_host + + rules: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.rules, Unset): + rules = self.rules.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if allow_out is not UNSET: + field_dict["allowOut"] = allow_out + if allow_public_traffic is not UNSET: + field_dict["allowPublicTraffic"] = allow_public_traffic + if deny_out is not UNSET: + field_dict["denyOut"] = deny_out + if mask_request_host is not UNSET: + field_dict["maskRequestHost"] = mask_request_host + if rules is not UNSET: + field_dict["rules"] = rules + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_network_config_rules import SandboxNetworkConfigRules + + d = dict(src_dict) + allow_out = cast(list[str], d.pop("allowOut", UNSET)) + + allow_public_traffic = d.pop("allowPublicTraffic", UNSET) + + deny_out = cast(list[str], d.pop("denyOut", UNSET)) + + mask_request_host = d.pop("maskRequestHost", UNSET) + + _rules = d.pop("rules", UNSET) + rules: Union[Unset, SandboxNetworkConfigRules] + if isinstance(_rules, Unset): + rules = UNSET + else: + rules = SandboxNetworkConfigRules.from_dict(_rules) + + sandbox_network_config = cls( + allow_out=allow_out, + allow_public_traffic=allow_public_traffic, + deny_out=deny_out, + mask_request_host=mask_request_host, + rules=rules, + ) + + sandbox_network_config.additional_properties = d + return sandbox_network_config + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_config_rules.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_config_rules.py new file mode 100644 index 0000000..aeece38 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_config_rules.py @@ -0,0 +1,72 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.sandbox_network_rule import SandboxNetworkRule + + +T = TypeVar("T", bound="SandboxNetworkConfigRules") + + +@_attrs_define +class SandboxNetworkConfigRules: + """Per-domain transform rules applied to matching egress HTTP/HTTPS requests. Keys are domains (e.g. "api.example.com", + "example.com"). A domain listed here is not automatically allowed - use allowOut to permit the traffic. + + """ + + additional_properties: dict[str, list["SandboxNetworkRule"]] = _attrs_field( + init=False, factory=dict + ) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + for prop_name, prop in self.additional_properties.items(): + field_dict[prop_name] = [] + for additional_property_item_data in prop: + additional_property_item = additional_property_item_data.to_dict() + field_dict[prop_name].append(additional_property_item) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_network_rule import SandboxNetworkRule + + d = dict(src_dict) + sandbox_network_config_rules = cls() + + additional_properties = {} + for prop_name, prop_dict in d.items(): + additional_property = [] + _additional_property = prop_dict + for additional_property_item_data in _additional_property: + additional_property_item = SandboxNetworkRule.from_dict( + additional_property_item_data + ) + + additional_property.append(additional_property_item) + + additional_properties[prop_name] = additional_property + + sandbox_network_config_rules.additional_properties = additional_properties + return sandbox_network_config_rules + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> list["SandboxNetworkRule"]: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: list["SandboxNetworkRule"]) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_rule.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_rule.py new file mode 100644 index 0000000..f1d0a30 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_rule.py @@ -0,0 +1,74 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.sandbox_network_transform import SandboxNetworkTransform + + +T = TypeVar("T", bound="SandboxNetworkRule") + + +@_attrs_define +class SandboxNetworkRule: + """Transform rule applied to egress requests matching a domain pattern. + + Attributes: + transform (Union[Unset, SandboxNetworkTransform]): Transformations applied to matching egress requests before + forwarding. + """ + + transform: Union[Unset, "SandboxNetworkTransform"] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + transform: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.transform, Unset): + transform = self.transform.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if transform is not UNSET: + field_dict["transform"] = transform + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_network_transform import SandboxNetworkTransform + + d = dict(src_dict) + _transform = d.pop("transform", UNSET) + transform: Union[Unset, SandboxNetworkTransform] + if isinstance(_transform, Unset): + transform = UNSET + else: + transform = SandboxNetworkTransform.from_dict(_transform) + + sandbox_network_rule = cls( + transform=transform, + ) + + sandbox_network_rule.additional_properties = d + return sandbox_network_rule + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_transform.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_transform.py new file mode 100644 index 0000000..d754240 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_transform.py @@ -0,0 +1,79 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.sandbox_network_transform_headers import ( + SandboxNetworkTransformHeaders, + ) + + +T = TypeVar("T", bound="SandboxNetworkTransform") + + +@_attrs_define +class SandboxNetworkTransform: + """Transformations applied to matching egress requests before forwarding. + + Attributes: + headers (Union[Unset, SandboxNetworkTransformHeaders]): HTTP headers to inject or override in matching requests. + An existing header with the same name is replaced. Values are plain strings; secret resolution happens client- + side before sending to the API. + """ + + headers: Union[Unset, "SandboxNetworkTransformHeaders"] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + headers: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.headers, Unset): + headers = self.headers.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if headers is not UNSET: + field_dict["headers"] = headers + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_network_transform_headers import ( + SandboxNetworkTransformHeaders, + ) + + d = dict(src_dict) + _headers = d.pop("headers", UNSET) + headers: Union[Unset, SandboxNetworkTransformHeaders] + if isinstance(_headers, Unset): + headers = UNSET + else: + headers = SandboxNetworkTransformHeaders.from_dict(_headers) + + sandbox_network_transform = cls( + headers=headers, + ) + + sandbox_network_transform.additional_properties = d + return sandbox_network_transform + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_transform_headers.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_transform_headers.py new file mode 100644 index 0000000..c2d7c1a --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_transform_headers.py @@ -0,0 +1,47 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SandboxNetworkTransformHeaders") + + +@_attrs_define +class SandboxNetworkTransformHeaders: + """HTTP headers to inject or override in matching requests. An existing header with the same name is replaced. Values + are plain strings; secret resolution happens client-side before sending to the API. + + """ + + additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + sandbox_network_transform_headers = cls() + + sandbox_network_transform_headers.additional_properties = d + return sandbox_network_transform_headers + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_update_config.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_update_config.py new file mode 100644 index 0000000..a4c5e8f --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_update_config.py @@ -0,0 +1,114 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.sandbox_network_update_config_rules import ( + SandboxNetworkUpdateConfigRules, + ) + + +T = TypeVar("T", bound="SandboxNetworkUpdateConfig") + + +@_attrs_define +class SandboxNetworkUpdateConfig: + """Network configuration update for a running sandbox. Replaces the current egress rules with the provided + configuration. Omitting a field clears it. + + Attributes: + allow_out (Union[Unset, list[str]]): List of allowed destinations for egress traffic. Each entry can be a CIDR + block (e.g. "8.8.8.8/32"), a bare IP address (e.g. "8.8.8.8"), or a domain name (e.g. "example.com", + "*.example.com"). Allowed entries always take precedence over denied entries. + allow_internet_access (Union[Unset, bool]): Allow sandbox to access the internet. When set to false, it behaves + the same as specifying denyOut to 0.0.0.0/0 in the network config. + deny_out (Union[Unset, list[str]]): List of denied CIDR blocks or IP addresses for egress traffic. Domain names + are not supported for deny rules. + rules (Union[Unset, SandboxNetworkUpdateConfigRules]): Per-domain transform rules. Replaces all existing rules + when provided. + """ + + allow_out: Union[Unset, list[str]] = UNSET + allow_internet_access: Union[Unset, bool] = UNSET + deny_out: Union[Unset, list[str]] = UNSET + rules: Union[Unset, "SandboxNetworkUpdateConfigRules"] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + allow_out: Union[Unset, list[str]] = UNSET + if not isinstance(self.allow_out, Unset): + allow_out = self.allow_out + + allow_internet_access = self.allow_internet_access + + deny_out: Union[Unset, list[str]] = UNSET + if not isinstance(self.deny_out, Unset): + deny_out = self.deny_out + + rules: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.rules, Unset): + rules = self.rules.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if allow_out is not UNSET: + field_dict["allowOut"] = allow_out + if allow_internet_access is not UNSET: + field_dict["allow_internet_access"] = allow_internet_access + if deny_out is not UNSET: + field_dict["denyOut"] = deny_out + if rules is not UNSET: + field_dict["rules"] = rules + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_network_update_config_rules import ( + SandboxNetworkUpdateConfigRules, + ) + + d = dict(src_dict) + allow_out = cast(list[str], d.pop("allowOut", UNSET)) + + allow_internet_access = d.pop("allow_internet_access", UNSET) + + deny_out = cast(list[str], d.pop("denyOut", UNSET)) + + _rules = d.pop("rules", UNSET) + rules: Union[Unset, SandboxNetworkUpdateConfigRules] + if isinstance(_rules, Unset): + rules = UNSET + else: + rules = SandboxNetworkUpdateConfigRules.from_dict(_rules) + + sandbox_network_update_config = cls( + allow_out=allow_out, + allow_internet_access=allow_internet_access, + deny_out=deny_out, + rules=rules, + ) + + sandbox_network_update_config.additional_properties = d + return sandbox_network_update_config + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_network_update_config_rules.py b/packages/python-sdk/e2b/api/client/models/sandbox_network_update_config_rules.py new file mode 100644 index 0000000..58d268a --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_network_update_config_rules.py @@ -0,0 +1,71 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.sandbox_network_rule import SandboxNetworkRule + + +T = TypeVar("T", bound="SandboxNetworkUpdateConfigRules") + + +@_attrs_define +class SandboxNetworkUpdateConfigRules: + """Per-domain transform rules. Replaces all existing rules when provided.""" + + additional_properties: dict[str, list["SandboxNetworkRule"]] = _attrs_field( + init=False, factory=dict + ) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + for prop_name, prop in self.additional_properties.items(): + field_dict[prop_name] = [] + for additional_property_item_data in prop: + additional_property_item = additional_property_item_data.to_dict() + field_dict[prop_name].append(additional_property_item) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_network_rule import SandboxNetworkRule + + d = dict(src_dict) + sandbox_network_update_config_rules = cls() + + additional_properties = {} + for prop_name, prop_dict in d.items(): + additional_property = [] + _additional_property = prop_dict + for additional_property_item_data in _additional_property: + additional_property_item = SandboxNetworkRule.from_dict( + additional_property_item_data + ) + + additional_property.append(additional_property_item) + + additional_properties[prop_name] = additional_property + + sandbox_network_update_config_rules.additional_properties = ( + additional_properties + ) + return sandbox_network_update_config_rules + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> list["SandboxNetworkRule"]: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: list["SandboxNetworkRule"]) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_on_timeout.py b/packages/python-sdk/e2b/api/client/models/sandbox_on_timeout.py new file mode 100644 index 0000000..8cd4cba --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_on_timeout.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class SandboxOnTimeout(str, Enum): + KILL = "kill" + PAUSE = "pause" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_pause_request.py b/packages/python-sdk/e2b/api/client/models/sandbox_pause_request.py new file mode 100644 index 0000000..0f36b2e --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_pause_request.py @@ -0,0 +1,62 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="SandboxPauseRequest") + + +@_attrs_define +class SandboxPauseRequest: + """ + Attributes: + memory (Union[Unset, bool]): Whether to capture a full memory snapshot. When false, only the filesystem is + persisted and resuming the sandbox cold-boots (reboots) it from disk, losing in-memory state, running processes, + and open connections. Resume it with an explicit request (connect or resume); auto-resume, which can be + triggered by arbitrary traffic, refuses such a sandbox. Defaults to true. Default: True. + """ + + memory: Union[Unset, bool] = True + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + memory = self.memory + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if memory is not UNSET: + field_dict["memory"] = memory + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + memory = d.pop("memory", UNSET) + + sandbox_pause_request = cls( + memory=memory, + ) + + sandbox_pause_request.additional_properties = d + return sandbox_pause_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_state.py b/packages/python-sdk/e2b/api/client/models/sandbox_state.py new file mode 100644 index 0000000..2ac2565 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_state.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class SandboxState(str, Enum): + PAUSED = "paused" + RUNNING = "running" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_volume_mount.py b/packages/python-sdk/e2b/api/client/models/sandbox_volume_mount.py new file mode 100644 index 0000000..946cbbf --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_volume_mount.py @@ -0,0 +1,67 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SandboxVolumeMount") + + +@_attrs_define +class SandboxVolumeMount: + """ + Attributes: + name (str): Name of the volume + path (str): Path of the volume + """ + + name: str + path: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + path = self.path + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + "path": path, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + path = d.pop("path") + + sandbox_volume_mount = cls( + name=name, + path=path, + ) + + sandbox_volume_mount.additional_properties = d + return sandbox_volume_mount + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandboxes_with_metrics.py b/packages/python-sdk/e2b/api/client/models/sandboxes_with_metrics.py new file mode 100644 index 0000000..64981d9 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandboxes_with_metrics.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SandboxesWithMetrics") + + +@_attrs_define +class SandboxesWithMetrics: + """ + Attributes: + sandboxes (Any): + """ + + sandboxes: Any + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + sandboxes = self.sandboxes + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "sandboxes": sandboxes, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + sandboxes = d.pop("sandboxes") + + sandboxes_with_metrics = cls( + sandboxes=sandboxes, + ) + + sandboxes_with_metrics.additional_properties = d + return sandboxes_with_metrics + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/snapshot_info.py b/packages/python-sdk/e2b/api/client/models/snapshot_info.py new file mode 100644 index 0000000..82b4726 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/snapshot_info.py @@ -0,0 +1,70 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SnapshotInfo") + + +@_attrs_define +class SnapshotInfo: + """ + Attributes: + names (list[str]): Full names of the snapshot template including team namespace and tag (e.g. team-slug/my- + snapshot:v2) + snapshot_id (str): Identifier of the snapshot template including the tag. Uses namespace/alias when a name was + provided (e.g. team-slug/my-snapshot:default), otherwise falls back to the raw template ID (e.g. + abc123:default). + """ + + names: list[str] + snapshot_id: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + names = self.names + + snapshot_id = self.snapshot_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "names": names, + "snapshotID": snapshot_id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + names = cast(list[str], d.pop("names")) + + snapshot_id = d.pop("snapshotID") + + snapshot_info = cls( + names=names, + snapshot_id=snapshot_id, + ) + + snapshot_info.additional_properties = d + return snapshot_info + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/team.py b/packages/python-sdk/e2b/api/client/models/team.py new file mode 100644 index 0000000..81a434c --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/team.py @@ -0,0 +1,83 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="Team") + + +@_attrs_define +class Team: + """ + Attributes: + api_key (str): API key for the team + is_default (bool): Whether the team is the default team + name (str): Name of the team + team_id (str): Identifier of the team + """ + + api_key: str + is_default: bool + name: str + team_id: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + api_key = self.api_key + + is_default = self.is_default + + name = self.name + + team_id = self.team_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "apiKey": api_key, + "isDefault": is_default, + "name": name, + "teamID": team_id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + api_key = d.pop("apiKey") + + is_default = d.pop("isDefault") + + name = d.pop("name") + + team_id = d.pop("teamID") + + team = cls( + api_key=api_key, + is_default=is_default, + name=name, + team_id=team_id, + ) + + team.additional_properties = d + return team + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/team_api_key.py b/packages/python-sdk/e2b/api/client/models/team_api_key.py new file mode 100644 index 0000000..7edbbc1 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/team_api_key.py @@ -0,0 +1,158 @@ +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.identifier_masking_details import IdentifierMaskingDetails + from ..models.team_user import TeamUser + + +T = TypeVar("T", bound="TeamAPIKey") + + +@_attrs_define +class TeamAPIKey: + """ + Attributes: + created_at (datetime.datetime): Timestamp of API key creation + id (UUID): Identifier of the API key + mask (IdentifierMaskingDetails): + name (str): Name of the API key + created_by (Union['TeamUser', None, Unset]): + last_used (Union[None, Unset, datetime.datetime]): Last time this API key was used + """ + + created_at: datetime.datetime + id: UUID + mask: "IdentifierMaskingDetails" + name: str + created_by: Union["TeamUser", None, Unset] = UNSET + last_used: Union[None, Unset, datetime.datetime] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.team_user import TeamUser + + created_at = self.created_at.isoformat() + + id = str(self.id) + + mask = self.mask.to_dict() + + name = self.name + + created_by: Union[None, Unset, dict[str, Any]] + if isinstance(self.created_by, Unset): + created_by = UNSET + elif isinstance(self.created_by, TeamUser): + created_by = self.created_by.to_dict() + else: + created_by = self.created_by + + last_used: Union[None, Unset, str] + if isinstance(self.last_used, Unset): + last_used = UNSET + elif isinstance(self.last_used, datetime.datetime): + last_used = self.last_used.isoformat() + else: + last_used = self.last_used + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "createdAt": created_at, + "id": id, + "mask": mask, + "name": name, + } + ) + if created_by is not UNSET: + field_dict["createdBy"] = created_by + if last_used is not UNSET: + field_dict["lastUsed"] = last_used + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.identifier_masking_details import IdentifierMaskingDetails + from ..models.team_user import TeamUser + + d = dict(src_dict) + created_at = isoparse(d.pop("createdAt")) + + id = UUID(d.pop("id")) + + mask = IdentifierMaskingDetails.from_dict(d.pop("mask")) + + name = d.pop("name") + + def _parse_created_by(data: object) -> Union["TeamUser", None, Unset]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + created_by_type_1 = TeamUser.from_dict(data) + + return created_by_type_1 + except: # noqa: E722 + pass + return cast(Union["TeamUser", None, Unset], data) + + created_by = _parse_created_by(d.pop("createdBy", UNSET)) + + def _parse_last_used(data: object) -> Union[None, Unset, datetime.datetime]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + last_used_type_0 = isoparse(data) + + return last_used_type_0 + except: # noqa: E722 + pass + return cast(Union[None, Unset, datetime.datetime], data) + + last_used = _parse_last_used(d.pop("lastUsed", UNSET)) + + team_api_key = cls( + created_at=created_at, + id=id, + mask=mask, + name=name, + created_by=created_by, + last_used=last_used, + ) + + team_api_key.additional_properties = d + return team_api_key + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/team_metric.py b/packages/python-sdk/e2b/api/client/models/team_metric.py new file mode 100644 index 0000000..97d30d7 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/team_metric.py @@ -0,0 +1,86 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +T = TypeVar("T", bound="TeamMetric") + + +@_attrs_define +class TeamMetric: + """Team metric with timestamp + + Attributes: + concurrent_sandboxes (int): The number of concurrent sandboxes for the team + sandbox_start_rate (float): Number of sandboxes started per second + timestamp (datetime.datetime): Timestamp of the metric entry + timestamp_unix (int): Timestamp of the metric entry in Unix time (seconds since epoch) + """ + + concurrent_sandboxes: int + sandbox_start_rate: float + timestamp: datetime.datetime + timestamp_unix: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + concurrent_sandboxes = self.concurrent_sandboxes + + sandbox_start_rate = self.sandbox_start_rate + + timestamp = self.timestamp.isoformat() + + timestamp_unix = self.timestamp_unix + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "concurrentSandboxes": concurrent_sandboxes, + "sandboxStartRate": sandbox_start_rate, + "timestamp": timestamp, + "timestampUnix": timestamp_unix, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + concurrent_sandboxes = d.pop("concurrentSandboxes") + + sandbox_start_rate = d.pop("sandboxStartRate") + + timestamp = isoparse(d.pop("timestamp")) + + timestamp_unix = d.pop("timestampUnix") + + team_metric = cls( + concurrent_sandboxes=concurrent_sandboxes, + sandbox_start_rate=sandbox_start_rate, + timestamp=timestamp, + timestamp_unix=timestamp_unix, + ) + + team_metric.additional_properties = d + return team_metric + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/team_user.py b/packages/python-sdk/e2b/api/client/models/team_user.py new file mode 100644 index 0000000..26491f7 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/team_user.py @@ -0,0 +1,75 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="TeamUser") + + +@_attrs_define +class TeamUser: + """ + Attributes: + email (Union[None, str]): Email of the user + id (UUID): Identifier of the user + """ + + email: Union[None, str] + id: UUID + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + email: Union[None, str] + email = self.email + + id = str(self.id) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "email": email, + "id": id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + + def _parse_email(data: object) -> Union[None, str]: + if data is None: + return data + return cast(Union[None, str], data) + + email = _parse_email(d.pop("email")) + + id = UUID(d.pop("id")) + + team_user = cls( + email=email, + id=id, + ) + + team_user.additional_properties = d + return team_user + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template.py b/packages/python-sdk/e2b/api/client/models/template.py new file mode 100644 index 0000000..c75d4d8 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template.py @@ -0,0 +1,225 @@ +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.template_build_status import TemplateBuildStatus + +if TYPE_CHECKING: + from ..models.team_user import TeamUser + + +T = TypeVar("T", bound="Template") + + +@_attrs_define +class Template: + """ + Attributes: + aliases (list[str]): Aliases of the template + build_count (int): Number of times the template was built + build_id (str): Identifier of the last successful build for given template + build_status (TemplateBuildStatus): Status of the template build + cpu_count (int): CPU cores for the sandbox + created_at (datetime.datetime): Time when the template was created + created_by (Union['TeamUser', None]): + disk_size_mb (int): Disk size for the sandbox in MiB + envd_version (str): Version of the envd running in the sandbox + last_spawned_at (Union[None, datetime.datetime]): Time when the template was last used + memory_mb (int): Memory for the sandbox in MiB + names (list[str]): Names of the template (namespace/alias format when namespaced) + public (bool): Whether the template is public or only accessible by the team + spawn_count (int): Number of times the template was used + template_id (str): Identifier of the template + updated_at (datetime.datetime): Time when the template was last updated + """ + + aliases: list[str] + build_count: int + build_id: str + build_status: TemplateBuildStatus + cpu_count: int + created_at: datetime.datetime + created_by: Union["TeamUser", None] + disk_size_mb: int + envd_version: str + last_spawned_at: Union[None, datetime.datetime] + memory_mb: int + names: list[str] + public: bool + spawn_count: int + template_id: str + updated_at: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.team_user import TeamUser + + aliases = self.aliases + + build_count = self.build_count + + build_id = self.build_id + + build_status = self.build_status.value + + cpu_count = self.cpu_count + + created_at = self.created_at.isoformat() + + created_by: Union[None, dict[str, Any]] + if isinstance(self.created_by, TeamUser): + created_by = self.created_by.to_dict() + else: + created_by = self.created_by + + disk_size_mb = self.disk_size_mb + + envd_version = self.envd_version + + last_spawned_at: Union[None, str] + if isinstance(self.last_spawned_at, datetime.datetime): + last_spawned_at = self.last_spawned_at.isoformat() + else: + last_spawned_at = self.last_spawned_at + + memory_mb = self.memory_mb + + names = self.names + + public = self.public + + spawn_count = self.spawn_count + + template_id = self.template_id + + updated_at = self.updated_at.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "aliases": aliases, + "buildCount": build_count, + "buildID": build_id, + "buildStatus": build_status, + "cpuCount": cpu_count, + "createdAt": created_at, + "createdBy": created_by, + "diskSizeMB": disk_size_mb, + "envdVersion": envd_version, + "lastSpawnedAt": last_spawned_at, + "memoryMB": memory_mb, + "names": names, + "public": public, + "spawnCount": spawn_count, + "templateID": template_id, + "updatedAt": updated_at, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.team_user import TeamUser + + d = dict(src_dict) + aliases = cast(list[str], d.pop("aliases")) + + build_count = d.pop("buildCount") + + build_id = d.pop("buildID") + + build_status = TemplateBuildStatus(d.pop("buildStatus")) + + cpu_count = d.pop("cpuCount") + + created_at = isoparse(d.pop("createdAt")) + + def _parse_created_by(data: object) -> Union["TeamUser", None]: + if data is None: + return data + try: + if not isinstance(data, dict): + raise TypeError() + created_by_type_1 = TeamUser.from_dict(data) + + return created_by_type_1 + except: # noqa: E722 + pass + return cast(Union["TeamUser", None], data) + + created_by = _parse_created_by(d.pop("createdBy")) + + disk_size_mb = d.pop("diskSizeMB") + + envd_version = d.pop("envdVersion") + + def _parse_last_spawned_at(data: object) -> Union[None, datetime.datetime]: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + last_spawned_at_type_0 = isoparse(data) + + return last_spawned_at_type_0 + except: # noqa: E722 + pass + return cast(Union[None, datetime.datetime], data) + + last_spawned_at = _parse_last_spawned_at(d.pop("lastSpawnedAt")) + + memory_mb = d.pop("memoryMB") + + names = cast(list[str], d.pop("names")) + + public = d.pop("public") + + spawn_count = d.pop("spawnCount") + + template_id = d.pop("templateID") + + updated_at = isoparse(d.pop("updatedAt")) + + template = cls( + aliases=aliases, + build_count=build_count, + build_id=build_id, + build_status=build_status, + cpu_count=cpu_count, + created_at=created_at, + created_by=created_by, + disk_size_mb=disk_size_mb, + envd_version=envd_version, + last_spawned_at=last_spawned_at, + memory_mb=memory_mb, + names=names, + public=public, + spawn_count=spawn_count, + template_id=template_id, + updated_at=updated_at, + ) + + template.additional_properties = d + return template + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_alias_response.py b/packages/python-sdk/e2b/api/client/models/template_alias_response.py new file mode 100644 index 0000000..7c91061 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_alias_response.py @@ -0,0 +1,67 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="TemplateAliasResponse") + + +@_attrs_define +class TemplateAliasResponse: + """ + Attributes: + public (bool): Whether the template is public or only accessible by the team + template_id (str): Identifier of the template + """ + + public: bool + template_id: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + public = self.public + + template_id = self.template_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "public": public, + "templateID": template_id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + public = d.pop("public") + + template_id = d.pop("templateID") + + template_alias_response = cls( + public=public, + template_id=template_id, + ) + + template_alias_response.additional_properties = d + return template_alias_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_build.py b/packages/python-sdk/e2b/api/client/models/template_build.py new file mode 100644 index 0000000..f42a3f6 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_build.py @@ -0,0 +1,139 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, Union +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.template_build_status import TemplateBuildStatus +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateBuild") + + +@_attrs_define +class TemplateBuild: + """ + Attributes: + build_id (UUID): Identifier of the build + cpu_count (int): CPU cores for the sandbox + created_at (datetime.datetime): Time when the build was created + memory_mb (int): Memory for the sandbox in MiB + status (TemplateBuildStatus): Status of the template build + updated_at (datetime.datetime): Time when the build was last updated + disk_size_mb (Union[Unset, int]): Disk size for the sandbox in MiB + envd_version (Union[Unset, str]): Version of the envd running in the sandbox + finished_at (Union[Unset, datetime.datetime]): Time when the build was finished + """ + + build_id: UUID + cpu_count: int + created_at: datetime.datetime + memory_mb: int + status: TemplateBuildStatus + updated_at: datetime.datetime + disk_size_mb: Union[Unset, int] = UNSET + envd_version: Union[Unset, str] = UNSET + finished_at: Union[Unset, datetime.datetime] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + build_id = str(self.build_id) + + cpu_count = self.cpu_count + + created_at = self.created_at.isoformat() + + memory_mb = self.memory_mb + + status = self.status.value + + updated_at = self.updated_at.isoformat() + + disk_size_mb = self.disk_size_mb + + envd_version = self.envd_version + + finished_at: Union[Unset, str] = UNSET + if not isinstance(self.finished_at, Unset): + finished_at = self.finished_at.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "buildID": build_id, + "cpuCount": cpu_count, + "createdAt": created_at, + "memoryMB": memory_mb, + "status": status, + "updatedAt": updated_at, + } + ) + if disk_size_mb is not UNSET: + field_dict["diskSizeMB"] = disk_size_mb + if envd_version is not UNSET: + field_dict["envdVersion"] = envd_version + if finished_at is not UNSET: + field_dict["finishedAt"] = finished_at + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + build_id = UUID(d.pop("buildID")) + + cpu_count = d.pop("cpuCount") + + created_at = isoparse(d.pop("createdAt")) + + memory_mb = d.pop("memoryMB") + + status = TemplateBuildStatus(d.pop("status")) + + updated_at = isoparse(d.pop("updatedAt")) + + disk_size_mb = d.pop("diskSizeMB", UNSET) + + envd_version = d.pop("envdVersion", UNSET) + + _finished_at = d.pop("finishedAt", UNSET) + finished_at: Union[Unset, datetime.datetime] + if isinstance(_finished_at, Unset): + finished_at = UNSET + else: + finished_at = isoparse(_finished_at) + + template_build = cls( + build_id=build_id, + cpu_count=cpu_count, + created_at=created_at, + memory_mb=memory_mb, + status=status, + updated_at=updated_at, + disk_size_mb=disk_size_mb, + envd_version=envd_version, + finished_at=finished_at, + ) + + template_build.additional_properties = d + return template_build + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_build_file_upload.py b/packages/python-sdk/e2b/api/client/models/template_build_file_upload.py new file mode 100644 index 0000000..a7d4e44 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_build_file_upload.py @@ -0,0 +1,70 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateBuildFileUpload") + + +@_attrs_define +class TemplateBuildFileUpload: + """ + Attributes: + present (bool): Whether the file is already present in the cache + url (Union[Unset, str]): Url where the file should be uploaded to + """ + + present: bool + url: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + present = self.present + + url = self.url + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "present": present, + } + ) + if url is not UNSET: + field_dict["url"] = url + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + present = d.pop("present") + + url = d.pop("url", UNSET) + + template_build_file_upload = cls( + present=present, + url=url, + ) + + template_build_file_upload.additional_properties = d + return template_build_file_upload + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_build_info.py b/packages/python-sdk/e2b/api/client/models/template_build_info.py new file mode 100644 index 0000000..90b670c --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_build_info.py @@ -0,0 +1,126 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.template_build_status import TemplateBuildStatus +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.build_log_entry import BuildLogEntry + from ..models.build_status_reason import BuildStatusReason + + +T = TypeVar("T", bound="TemplateBuildInfo") + + +@_attrs_define +class TemplateBuildInfo: + """ + Attributes: + build_id (str): Identifier of the build + log_entries (list['BuildLogEntry']): Build logs structured + logs (list[str]): Build logs + status (TemplateBuildStatus): Status of the template build + template_id (str): Identifier of the template + reason (Union[Unset, BuildStatusReason]): + """ + + build_id: str + log_entries: list["BuildLogEntry"] + logs: list[str] + status: TemplateBuildStatus + template_id: str + reason: Union[Unset, "BuildStatusReason"] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + build_id = self.build_id + + log_entries = [] + for log_entries_item_data in self.log_entries: + log_entries_item = log_entries_item_data.to_dict() + log_entries.append(log_entries_item) + + logs = self.logs + + status = self.status.value + + template_id = self.template_id + + reason: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.reason, Unset): + reason = self.reason.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "buildID": build_id, + "logEntries": log_entries, + "logs": logs, + "status": status, + "templateID": template_id, + } + ) + if reason is not UNSET: + field_dict["reason"] = reason + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.build_log_entry import BuildLogEntry + from ..models.build_status_reason import BuildStatusReason + + d = dict(src_dict) + build_id = d.pop("buildID") + + log_entries = [] + _log_entries = d.pop("logEntries") + for log_entries_item_data in _log_entries: + log_entries_item = BuildLogEntry.from_dict(log_entries_item_data) + + log_entries.append(log_entries_item) + + logs = cast(list[str], d.pop("logs")) + + status = TemplateBuildStatus(d.pop("status")) + + template_id = d.pop("templateID") + + _reason = d.pop("reason", UNSET) + reason: Union[Unset, BuildStatusReason] + if isinstance(_reason, Unset): + reason = UNSET + else: + reason = BuildStatusReason.from_dict(_reason) + + template_build_info = cls( + build_id=build_id, + log_entries=log_entries, + logs=logs, + status=status, + template_id=template_id, + reason=reason, + ) + + template_build_info.additional_properties = d + return template_build_info + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_build_logs_response.py b/packages/python-sdk/e2b/api/client/models/template_build_logs_response.py new file mode 100644 index 0000000..6f3f709 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_build_logs_response.py @@ -0,0 +1,73 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.build_log_entry import BuildLogEntry + + +T = TypeVar("T", bound="TemplateBuildLogsResponse") + + +@_attrs_define +class TemplateBuildLogsResponse: + """ + Attributes: + logs (list['BuildLogEntry']): Build logs structured + """ + + logs: list["BuildLogEntry"] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + logs = [] + for logs_item_data in self.logs: + logs_item = logs_item_data.to_dict() + logs.append(logs_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "logs": logs, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.build_log_entry import BuildLogEntry + + d = dict(src_dict) + logs = [] + _logs = d.pop("logs") + for logs_item_data in _logs: + logs_item = BuildLogEntry.from_dict(logs_item_data) + + logs.append(logs_item) + + template_build_logs_response = cls( + logs=logs, + ) + + template_build_logs_response.additional_properties = d + return template_build_logs_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_build_request.py b/packages/python-sdk/e2b/api/client/models/template_build_request.py new file mode 100644 index 0000000..b41e1d0 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_build_request.py @@ -0,0 +1,115 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateBuildRequest") + + +@_attrs_define +class TemplateBuildRequest: + """ + Attributes: + dockerfile (str): Dockerfile for the template + alias (Union[Unset, str]): Alias of the template + cpu_count (Union[Unset, int]): CPU cores for the sandbox + memory_mb (Union[Unset, int]): Memory for the sandbox in MiB + ready_cmd (Union[Unset, str]): Ready check command to execute in the template after the build + start_cmd (Union[Unset, str]): Start command to execute in the template after the build + team_id (Union[Unset, str]): Identifier of the team + """ + + dockerfile: str + alias: Union[Unset, str] = UNSET + cpu_count: Union[Unset, int] = UNSET + memory_mb: Union[Unset, int] = UNSET + ready_cmd: Union[Unset, str] = UNSET + start_cmd: Union[Unset, str] = UNSET + team_id: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + dockerfile = self.dockerfile + + alias = self.alias + + cpu_count = self.cpu_count + + memory_mb = self.memory_mb + + ready_cmd = self.ready_cmd + + start_cmd = self.start_cmd + + team_id = self.team_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "dockerfile": dockerfile, + } + ) + if alias is not UNSET: + field_dict["alias"] = alias + if cpu_count is not UNSET: + field_dict["cpuCount"] = cpu_count + if memory_mb is not UNSET: + field_dict["memoryMB"] = memory_mb + if ready_cmd is not UNSET: + field_dict["readyCmd"] = ready_cmd + if start_cmd is not UNSET: + field_dict["startCmd"] = start_cmd + if team_id is not UNSET: + field_dict["teamID"] = team_id + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + dockerfile = d.pop("dockerfile") + + alias = d.pop("alias", UNSET) + + cpu_count = d.pop("cpuCount", UNSET) + + memory_mb = d.pop("memoryMB", UNSET) + + ready_cmd = d.pop("readyCmd", UNSET) + + start_cmd = d.pop("startCmd", UNSET) + + team_id = d.pop("teamID", UNSET) + + template_build_request = cls( + dockerfile=dockerfile, + alias=alias, + cpu_count=cpu_count, + memory_mb=memory_mb, + ready_cmd=ready_cmd, + start_cmd=start_cmd, + team_id=team_id, + ) + + template_build_request.additional_properties = d + return template_build_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_build_request_v2.py b/packages/python-sdk/e2b/api/client/models/template_build_request_v2.py new file mode 100644 index 0000000..1473424 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_build_request_v2.py @@ -0,0 +1,88 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateBuildRequestV2") + + +@_attrs_define +class TemplateBuildRequestV2: + """ + Attributes: + alias (str): Alias of the template + cpu_count (Union[Unset, int]): CPU cores for the sandbox + memory_mb (Union[Unset, int]): Memory for the sandbox in MiB + team_id (Union[Unset, str]): Identifier of the team + """ + + alias: str + cpu_count: Union[Unset, int] = UNSET + memory_mb: Union[Unset, int] = UNSET + team_id: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + alias = self.alias + + cpu_count = self.cpu_count + + memory_mb = self.memory_mb + + team_id = self.team_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "alias": alias, + } + ) + if cpu_count is not UNSET: + field_dict["cpuCount"] = cpu_count + if memory_mb is not UNSET: + field_dict["memoryMB"] = memory_mb + if team_id is not UNSET: + field_dict["teamID"] = team_id + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + alias = d.pop("alias") + + cpu_count = d.pop("cpuCount", UNSET) + + memory_mb = d.pop("memoryMB", UNSET) + + team_id = d.pop("teamID", UNSET) + + template_build_request_v2 = cls( + alias=alias, + cpu_count=cpu_count, + memory_mb=memory_mb, + team_id=team_id, + ) + + template_build_request_v2.additional_properties = d + return template_build_request_v2 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_build_request_v3.py b/packages/python-sdk/e2b/api/client/models/template_build_request_v3.py new file mode 100644 index 0000000..dda90f0 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_build_request_v3.py @@ -0,0 +1,107 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateBuildRequestV3") + + +@_attrs_define +class TemplateBuildRequestV3: + """ + Attributes: + alias (Union[Unset, str]): Alias of the template. Deprecated, use name instead. + cpu_count (Union[Unset, int]): CPU cores for the sandbox + memory_mb (Union[Unset, int]): Memory for the sandbox in MiB + name (Union[Unset, str]): Name of the template. Can include a tag with colon separator (e.g. "my-template" or + "my-template:v1"). If tag is included, it will be treated as if the tag was provided in the tags array. + tags (Union[Unset, list[str]]): Tags to assign to the template build + team_id (Union[Unset, str]): Identifier of the team + """ + + alias: Union[Unset, str] = UNSET + cpu_count: Union[Unset, int] = UNSET + memory_mb: Union[Unset, int] = UNSET + name: Union[Unset, str] = UNSET + tags: Union[Unset, list[str]] = UNSET + team_id: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + alias = self.alias + + cpu_count = self.cpu_count + + memory_mb = self.memory_mb + + name = self.name + + tags: Union[Unset, list[str]] = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + team_id = self.team_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if alias is not UNSET: + field_dict["alias"] = alias + if cpu_count is not UNSET: + field_dict["cpuCount"] = cpu_count + if memory_mb is not UNSET: + field_dict["memoryMB"] = memory_mb + if name is not UNSET: + field_dict["name"] = name + if tags is not UNSET: + field_dict["tags"] = tags + if team_id is not UNSET: + field_dict["teamID"] = team_id + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + alias = d.pop("alias", UNSET) + + cpu_count = d.pop("cpuCount", UNSET) + + memory_mb = d.pop("memoryMB", UNSET) + + name = d.pop("name", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + team_id = d.pop("teamID", UNSET) + + template_build_request_v3 = cls( + alias=alias, + cpu_count=cpu_count, + memory_mb=memory_mb, + name=name, + tags=tags, + team_id=team_id, + ) + + template_build_request_v3.additional_properties = d + return template_build_request_v3 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_build_start_v2.py b/packages/python-sdk/e2b/api/client/models/template_build_start_v2.py new file mode 100644 index 0000000..32adf84 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_build_start_v2.py @@ -0,0 +1,184 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.aws_registry import AWSRegistry + from ..models.gcp_registry import GCPRegistry + from ..models.general_registry import GeneralRegistry + from ..models.template_step import TemplateStep + + +T = TypeVar("T", bound="TemplateBuildStartV2") + + +@_attrs_define +class TemplateBuildStartV2: + """ + Attributes: + force (Union[Unset, bool]): Whether the whole build should be forced to run regardless of the cache Default: + False. + from_image (Union[Unset, str]): Image to use as a base for the template build + from_image_registry (Union['AWSRegistry', 'GCPRegistry', 'GeneralRegistry', Unset]): + from_template (Union[Unset, str]): Template to use as a base for the template build + ready_cmd (Union[Unset, str]): Ready check command to execute in the template after the build + start_cmd (Union[Unset, str]): Start command to execute in the template after the build + steps (Union[Unset, list['TemplateStep']]): List of steps to execute in the template build + """ + + force: Union[Unset, bool] = False + from_image: Union[Unset, str] = UNSET + from_image_registry: Union[ + "AWSRegistry", "GCPRegistry", "GeneralRegistry", Unset + ] = UNSET + from_template: Union[Unset, str] = UNSET + ready_cmd: Union[Unset, str] = UNSET + start_cmd: Union[Unset, str] = UNSET + steps: Union[Unset, list["TemplateStep"]] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.aws_registry import AWSRegistry + from ..models.gcp_registry import GCPRegistry + + force = self.force + + from_image = self.from_image + + from_image_registry: Union[Unset, dict[str, Any]] + if isinstance(self.from_image_registry, Unset): + from_image_registry = UNSET + elif isinstance(self.from_image_registry, AWSRegistry): + from_image_registry = self.from_image_registry.to_dict() + elif isinstance(self.from_image_registry, GCPRegistry): + from_image_registry = self.from_image_registry.to_dict() + else: + from_image_registry = self.from_image_registry.to_dict() + + from_template = self.from_template + + ready_cmd = self.ready_cmd + + start_cmd = self.start_cmd + + steps: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.steps, Unset): + steps = [] + for steps_item_data in self.steps: + steps_item = steps_item_data.to_dict() + steps.append(steps_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if force is not UNSET: + field_dict["force"] = force + if from_image is not UNSET: + field_dict["fromImage"] = from_image + if from_image_registry is not UNSET: + field_dict["fromImageRegistry"] = from_image_registry + if from_template is not UNSET: + field_dict["fromTemplate"] = from_template + if ready_cmd is not UNSET: + field_dict["readyCmd"] = ready_cmd + if start_cmd is not UNSET: + field_dict["startCmd"] = start_cmd + if steps is not UNSET: + field_dict["steps"] = steps + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.aws_registry import AWSRegistry + from ..models.gcp_registry import GCPRegistry + from ..models.general_registry import GeneralRegistry + from ..models.template_step import TemplateStep + + d = dict(src_dict) + force = d.pop("force", UNSET) + + from_image = d.pop("fromImage", UNSET) + + def _parse_from_image_registry( + data: object, + ) -> Union["AWSRegistry", "GCPRegistry", "GeneralRegistry", Unset]: + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_from_image_registry_type_0 = AWSRegistry.from_dict( + data + ) + + return componentsschemas_from_image_registry_type_0 + except: # noqa: E722 + pass + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_from_image_registry_type_1 = GCPRegistry.from_dict( + data + ) + + return componentsschemas_from_image_registry_type_1 + except: # noqa: E722 + pass + if not isinstance(data, dict): + raise TypeError() + componentsschemas_from_image_registry_type_2 = GeneralRegistry.from_dict( + data + ) + + return componentsschemas_from_image_registry_type_2 + + from_image_registry = _parse_from_image_registry( + d.pop("fromImageRegistry", UNSET) + ) + + from_template = d.pop("fromTemplate", UNSET) + + ready_cmd = d.pop("readyCmd", UNSET) + + start_cmd = d.pop("startCmd", UNSET) + + steps = [] + _steps = d.pop("steps", UNSET) + for steps_item_data in _steps or []: + steps_item = TemplateStep.from_dict(steps_item_data) + + steps.append(steps_item) + + template_build_start_v2 = cls( + force=force, + from_image=from_image, + from_image_registry=from_image_registry, + from_template=from_template, + ready_cmd=ready_cmd, + start_cmd=start_cmd, + steps=steps, + ) + + template_build_start_v2.additional_properties = d + return template_build_start_v2 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_build_status.py b/packages/python-sdk/e2b/api/client/models/template_build_status.py new file mode 100644 index 0000000..6cae835 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_build_status.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class TemplateBuildStatus(str, Enum): + BUILDING = "building" + ERROR = "error" + READY = "ready" + WAITING = "waiting" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/api/client/models/template_legacy.py b/packages/python-sdk/e2b/api/client/models/template_legacy.py new file mode 100644 index 0000000..69501e5 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_legacy.py @@ -0,0 +1,207 @@ +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +if TYPE_CHECKING: + from ..models.team_user import TeamUser + + +T = TypeVar("T", bound="TemplateLegacy") + + +@_attrs_define +class TemplateLegacy: + """ + Attributes: + aliases (list[str]): Aliases of the template + build_count (int): Number of times the template was built + build_id (str): Identifier of the last successful build for given template + cpu_count (int): CPU cores for the sandbox + created_at (datetime.datetime): Time when the template was created + created_by (Union['TeamUser', None]): + disk_size_mb (int): Disk size for the sandbox in MiB + envd_version (str): Version of the envd running in the sandbox + last_spawned_at (Union[None, datetime.datetime]): Time when the template was last used + memory_mb (int): Memory for the sandbox in MiB + public (bool): Whether the template is public or only accessible by the team + spawn_count (int): Number of times the template was used + template_id (str): Identifier of the template + updated_at (datetime.datetime): Time when the template was last updated + """ + + aliases: list[str] + build_count: int + build_id: str + cpu_count: int + created_at: datetime.datetime + created_by: Union["TeamUser", None] + disk_size_mb: int + envd_version: str + last_spawned_at: Union[None, datetime.datetime] + memory_mb: int + public: bool + spawn_count: int + template_id: str + updated_at: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.team_user import TeamUser + + aliases = self.aliases + + build_count = self.build_count + + build_id = self.build_id + + cpu_count = self.cpu_count + + created_at = self.created_at.isoformat() + + created_by: Union[None, dict[str, Any]] + if isinstance(self.created_by, TeamUser): + created_by = self.created_by.to_dict() + else: + created_by = self.created_by + + disk_size_mb = self.disk_size_mb + + envd_version = self.envd_version + + last_spawned_at: Union[None, str] + if isinstance(self.last_spawned_at, datetime.datetime): + last_spawned_at = self.last_spawned_at.isoformat() + else: + last_spawned_at = self.last_spawned_at + + memory_mb = self.memory_mb + + public = self.public + + spawn_count = self.spawn_count + + template_id = self.template_id + + updated_at = self.updated_at.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "aliases": aliases, + "buildCount": build_count, + "buildID": build_id, + "cpuCount": cpu_count, + "createdAt": created_at, + "createdBy": created_by, + "diskSizeMB": disk_size_mb, + "envdVersion": envd_version, + "lastSpawnedAt": last_spawned_at, + "memoryMB": memory_mb, + "public": public, + "spawnCount": spawn_count, + "templateID": template_id, + "updatedAt": updated_at, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.team_user import TeamUser + + d = dict(src_dict) + aliases = cast(list[str], d.pop("aliases")) + + build_count = d.pop("buildCount") + + build_id = d.pop("buildID") + + cpu_count = d.pop("cpuCount") + + created_at = isoparse(d.pop("createdAt")) + + def _parse_created_by(data: object) -> Union["TeamUser", None]: + if data is None: + return data + try: + if not isinstance(data, dict): + raise TypeError() + created_by_type_1 = TeamUser.from_dict(data) + + return created_by_type_1 + except: # noqa: E722 + pass + return cast(Union["TeamUser", None], data) + + created_by = _parse_created_by(d.pop("createdBy")) + + disk_size_mb = d.pop("diskSizeMB") + + envd_version = d.pop("envdVersion") + + def _parse_last_spawned_at(data: object) -> Union[None, datetime.datetime]: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + last_spawned_at_type_0 = isoparse(data) + + return last_spawned_at_type_0 + except: # noqa: E722 + pass + return cast(Union[None, datetime.datetime], data) + + last_spawned_at = _parse_last_spawned_at(d.pop("lastSpawnedAt")) + + memory_mb = d.pop("memoryMB") + + public = d.pop("public") + + spawn_count = d.pop("spawnCount") + + template_id = d.pop("templateID") + + updated_at = isoparse(d.pop("updatedAt")) + + template_legacy = cls( + aliases=aliases, + build_count=build_count, + build_id=build_id, + cpu_count=cpu_count, + created_at=created_at, + created_by=created_by, + disk_size_mb=disk_size_mb, + envd_version=envd_version, + last_spawned_at=last_spawned_at, + memory_mb=memory_mb, + public=public, + spawn_count=spawn_count, + template_id=template_id, + updated_at=updated_at, + ) + + template_legacy.additional_properties = d + return template_legacy + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_request_response_v3.py b/packages/python-sdk/e2b/api/client/models/template_request_response_v3.py new file mode 100644 index 0000000..9921980 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_request_response_v3.py @@ -0,0 +1,99 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="TemplateRequestResponseV3") + + +@_attrs_define +class TemplateRequestResponseV3: + """ + Attributes: + aliases (list[str]): Aliases of the template + build_id (str): Identifier of the last successful build for given template + names (list[str]): Names of the template + public (bool): Whether the template is public or only accessible by the team + tags (list[str]): Tags assigned to the template build + template_id (str): Identifier of the template + """ + + aliases: list[str] + build_id: str + names: list[str] + public: bool + tags: list[str] + template_id: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + aliases = self.aliases + + build_id = self.build_id + + names = self.names + + public = self.public + + tags = self.tags + + template_id = self.template_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "aliases": aliases, + "buildID": build_id, + "names": names, + "public": public, + "tags": tags, + "templateID": template_id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + aliases = cast(list[str], d.pop("aliases")) + + build_id = d.pop("buildID") + + names = cast(list[str], d.pop("names")) + + public = d.pop("public") + + tags = cast(list[str], d.pop("tags")) + + template_id = d.pop("templateID") + + template_request_response_v3 = cls( + aliases=aliases, + build_id=build_id, + names=names, + public=public, + tags=tags, + template_id=template_id, + ) + + template_request_response_v3.additional_properties = d + return template_request_response_v3 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_step.py b/packages/python-sdk/e2b/api/client/models/template_step.py new file mode 100644 index 0000000..45daaec --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_step.py @@ -0,0 +1,91 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateStep") + + +@_attrs_define +class TemplateStep: + """Step in the template build process + + Attributes: + type_ (str): Type of the step + args (Union[Unset, list[str]]): Arguments for the step + files_hash (Union[Unset, str]): Hash of the files used in the step + force (Union[Unset, bool]): Whether the step should be forced to run regardless of the cache Default: False. + """ + + type_: str + args: Union[Unset, list[str]] = UNSET + files_hash: Union[Unset, str] = UNSET + force: Union[Unset, bool] = False + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + args: Union[Unset, list[str]] = UNSET + if not isinstance(self.args, Unset): + args = self.args + + files_hash = self.files_hash + + force = self.force + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + } + ) + if args is not UNSET: + field_dict["args"] = args + if files_hash is not UNSET: + field_dict["filesHash"] = files_hash + if force is not UNSET: + field_dict["force"] = force + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = d.pop("type") + + args = cast(list[str], d.pop("args", UNSET)) + + files_hash = d.pop("filesHash", UNSET) + + force = d.pop("force", UNSET) + + template_step = cls( + type_=type_, + args=args, + files_hash=files_hash, + force=force, + ) + + template_step.additional_properties = d + return template_step + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_tag.py b/packages/python-sdk/e2b/api/client/models/template_tag.py new file mode 100644 index 0000000..cd26809 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_tag.py @@ -0,0 +1,78 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +T = TypeVar("T", bound="TemplateTag") + + +@_attrs_define +class TemplateTag: + """ + Attributes: + build_id (UUID): Identifier of the build associated with this tag + created_at (datetime.datetime): Time when the tag was assigned + tag (str): The tag name + """ + + build_id: UUID + created_at: datetime.datetime + tag: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + build_id = str(self.build_id) + + created_at = self.created_at.isoformat() + + tag = self.tag + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "buildID": build_id, + "createdAt": created_at, + "tag": tag, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + build_id = UUID(d.pop("buildID")) + + created_at = isoparse(d.pop("createdAt")) + + tag = d.pop("tag") + + template_tag = cls( + build_id=build_id, + created_at=created_at, + tag=tag, + ) + + template_tag.additional_properties = d + return template_tag + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_update_request.py b/packages/python-sdk/e2b/api/client/models/template_update_request.py new file mode 100644 index 0000000..8a9f5be --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_update_request.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateUpdateRequest") + + +@_attrs_define +class TemplateUpdateRequest: + """ + Attributes: + public (Union[Unset, bool]): Whether the template is public or only accessible by the team + """ + + public: Union[Unset, bool] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + public = self.public + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if public is not UNSET: + field_dict["public"] = public + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + public = d.pop("public", UNSET) + + template_update_request = cls( + public=public, + ) + + template_update_request.additional_properties = d + return template_update_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_update_response.py b/packages/python-sdk/e2b/api/client/models/template_update_response.py new file mode 100644 index 0000000..7a273c8 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_update_response.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="TemplateUpdateResponse") + + +@_attrs_define +class TemplateUpdateResponse: + """ + Attributes: + names (list[str]): Names of the template (namespace/alias format when namespaced) + """ + + names: list[str] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + names = self.names + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "names": names, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + names = cast(list[str], d.pop("names")) + + template_update_response = cls( + names=names, + ) + + template_update_response.additional_properties = d + return template_update_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_with_builds.py b/packages/python-sdk/e2b/api/client/models/template_with_builds.py new file mode 100644 index 0000000..90c0a63 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_with_builds.py @@ -0,0 +1,156 @@ +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +if TYPE_CHECKING: + from ..models.template_build import TemplateBuild + + +T = TypeVar("T", bound="TemplateWithBuilds") + + +@_attrs_define +class TemplateWithBuilds: + """ + Attributes: + aliases (list[str]): Aliases of the template + builds (list['TemplateBuild']): List of builds for the template + created_at (datetime.datetime): Time when the template was created + last_spawned_at (Union[None, datetime.datetime]): Time when the template was last used + names (list[str]): Names of the template (namespace/alias format when namespaced) + public (bool): Whether the template is public or only accessible by the team + spawn_count (int): Number of times the template was used + template_id (str): Identifier of the template + updated_at (datetime.datetime): Time when the template was last updated + """ + + aliases: list[str] + builds: list["TemplateBuild"] + created_at: datetime.datetime + last_spawned_at: Union[None, datetime.datetime] + names: list[str] + public: bool + spawn_count: int + template_id: str + updated_at: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + aliases = self.aliases + + builds = [] + for builds_item_data in self.builds: + builds_item = builds_item_data.to_dict() + builds.append(builds_item) + + created_at = self.created_at.isoformat() + + last_spawned_at: Union[None, str] + if isinstance(self.last_spawned_at, datetime.datetime): + last_spawned_at = self.last_spawned_at.isoformat() + else: + last_spawned_at = self.last_spawned_at + + names = self.names + + public = self.public + + spawn_count = self.spawn_count + + template_id = self.template_id + + updated_at = self.updated_at.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "aliases": aliases, + "builds": builds, + "createdAt": created_at, + "lastSpawnedAt": last_spawned_at, + "names": names, + "public": public, + "spawnCount": spawn_count, + "templateID": template_id, + "updatedAt": updated_at, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.template_build import TemplateBuild + + d = dict(src_dict) + aliases = cast(list[str], d.pop("aliases")) + + builds = [] + _builds = d.pop("builds") + for builds_item_data in _builds: + builds_item = TemplateBuild.from_dict(builds_item_data) + + builds.append(builds_item) + + created_at = isoparse(d.pop("createdAt")) + + def _parse_last_spawned_at(data: object) -> Union[None, datetime.datetime]: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + last_spawned_at_type_0 = isoparse(data) + + return last_spawned_at_type_0 + except: # noqa: E722 + pass + return cast(Union[None, datetime.datetime], data) + + last_spawned_at = _parse_last_spawned_at(d.pop("lastSpawnedAt")) + + names = cast(list[str], d.pop("names")) + + public = d.pop("public") + + spawn_count = d.pop("spawnCount") + + template_id = d.pop("templateID") + + updated_at = isoparse(d.pop("updatedAt")) + + template_with_builds = cls( + aliases=aliases, + builds=builds, + created_at=created_at, + last_spawned_at=last_spawned_at, + names=names, + public=public, + spawn_count=spawn_count, + template_id=template_id, + updated_at=updated_at, + ) + + template_with_builds.additional_properties = d + return template_with_builds + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/update_team_api_key.py b/packages/python-sdk/e2b/api/client/models/update_team_api_key.py new file mode 100644 index 0000000..34fb967 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/update_team_api_key.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="UpdateTeamAPIKey") + + +@_attrs_define +class UpdateTeamAPIKey: + """ + Attributes: + name (str): New name for the API key + """ + + name: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + update_team_api_key = cls( + name=name, + ) + + update_team_api_key.additional_properties = d + return update_team_api_key + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/volume.py b/packages/python-sdk/e2b/api/client/models/volume.py new file mode 100644 index 0000000..7747498 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/volume.py @@ -0,0 +1,67 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="Volume") + + +@_attrs_define +class Volume: + """ + Attributes: + name (str): Name of the volume + volume_id (str): ID of the volume + """ + + name: str + volume_id: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + volume_id = self.volume_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + "volumeID": volume_id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + volume_id = d.pop("volumeID") + + volume = cls( + name=name, + volume_id=volume_id, + ) + + volume.additional_properties = d + return volume + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/volume_and_token.py b/packages/python-sdk/e2b/api/client/models/volume_and_token.py new file mode 100644 index 0000000..5f57d46 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/volume_and_token.py @@ -0,0 +1,75 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="VolumeAndToken") + + +@_attrs_define +class VolumeAndToken: + """ + Attributes: + name (str): Name of the volume + token (str): Auth token to use for interacting with volume content + volume_id (str): ID of the volume + """ + + name: str + token: str + volume_id: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + token = self.token + + volume_id = self.volume_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + "token": token, + "volumeID": volume_id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + token = d.pop("token") + + volume_id = d.pop("volumeID") + + volume_and_token = cls( + name=name, + token=token, + volume_id=volume_id, + ) + + volume_and_token.additional_properties = d + return volume_and_token + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/volume_token.py b/packages/python-sdk/e2b/api/client/models/volume_token.py new file mode 100644 index 0000000..f6f4cec --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/volume_token.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="VolumeToken") + + +@_attrs_define +class VolumeToken: + """ + Attributes: + token (str): + """ + + token: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + token = self.token + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "token": token, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + token = d.pop("token") + + volume_token = cls( + token=token, + ) + + volume_token.additional_properties = d + return volume_token + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/py.typed b/packages/python-sdk/e2b/api/client/py.typed new file mode 100644 index 0000000..1aad327 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 \ No newline at end of file diff --git a/packages/python-sdk/e2b/api/client/types.py b/packages/python-sdk/e2b/api/client/types.py new file mode 100644 index 0000000..1b96ca4 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/types.py @@ -0,0 +1,54 @@ +"""Contains some shared types for properties""" + +from collections.abc import Mapping, MutableMapping +from http import HTTPStatus +from typing import IO, BinaryIO, Generic, Literal, Optional, TypeVar, Union + +from attrs import define + + +class Unset: + def __bool__(self) -> Literal[False]: + return False + + +UNSET: Unset = Unset() + +# The types that `httpx.Client(files=)` can accept, copied from that library. +FileContent = Union[IO[bytes], bytes, str] +FileTypes = Union[ + # (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 = list[tuple[str, FileTypes]] + + +@define +class File: + """Contains information for file uploads""" + + payload: BinaryIO + file_name: Optional[str] = None + mime_type: Optional[str] = None + + def to_tuple(self) -> FileTypes: + """Return a tuple representation that httpx will accept for multipart/form-data""" + return self.file_name, self.payload, self.mime_type + + +T = TypeVar("T") + + +@define +class Response(Generic[T]): + """A response from an endpoint""" + + status_code: HTTPStatus + content: bytes + headers: MutableMapping[str, str] + parsed: Optional[T] + + +__all__ = ["UNSET", "File", "FileTypes", "RequestFiles", "Response", "Unset"] diff --git a/packages/python-sdk/e2b/api/client_async/__init__.py b/packages/python-sdk/e2b/api/client_async/__init__.py new file mode 100644 index 0000000..dd001a3 --- /dev/null +++ b/packages/python-sdk/e2b/api/client_async/__init__.py @@ -0,0 +1,74 @@ +import asyncio +import weakref +from typing import Dict, Optional, Tuple + +import httpx + +from httpx._types import ProxyTypes + +from e2b.api import AsyncApiClient, connection_retries, limits +from e2b.connection_config import ConnectionConfig + +TransportKey = Tuple[bool, Optional[ProxyTypes]] + + +def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient: + return AsyncApiClient( + config, + async_transport_factory=lambda: get_transport(config), + **kwargs, + ) + + +class AsyncTransportWithLogger(httpx.AsyncHTTPTransport): + # Keyed weakly by the event loop object itself, not id(loop) — CPython + # reuses object ids, so a new loop could otherwise inherit a transport + # bound to a previous, closed loop. + _instances: weakref.WeakKeyDictionary[ + asyncio.AbstractEventLoop, + Dict[TransportKey, "AsyncTransportWithLogger"], + ] = weakref.WeakKeyDictionary() + + @property + def pool(self): + return self._pool + + +def _get_cached_transport(cls, config: ConnectionConfig, http2: bool): + loop = asyncio.get_running_loop() + loop_instances = cls._instances.get(loop) + if loop_instances is None: + loop_instances = {} + cls._instances[loop] = loop_instances + + key: TransportKey = (http2, config.proxy) + transport = loop_instances.get(key) + if transport is None: + transport = cls( + limits=limits, + proxy=config.proxy, + http2=http2, + retries=connection_retries, + ) + loop_instances[key] = transport + + return transport + + +def get_transport( + config: ConnectionConfig, http2: bool = True +) -> AsyncTransportWithLogger: + return _get_cached_transport(AsyncTransportWithLogger, config, http2) + + +class AsyncEnvdTransportWithLogger(AsyncTransportWithLogger): + _instances: weakref.WeakKeyDictionary[ + asyncio.AbstractEventLoop, + Dict[TransportKey, "AsyncEnvdTransportWithLogger"], + ] = weakref.WeakKeyDictionary() + + +def get_envd_transport( + config: ConnectionConfig, http2: bool = True +) -> AsyncEnvdTransportWithLogger: + return _get_cached_transport(AsyncEnvdTransportWithLogger, config, http2) diff --git a/packages/python-sdk/e2b/api/client_sync/__init__.py b/packages/python-sdk/e2b/api/client_sync/__init__.py new file mode 100644 index 0000000..41fb4f0 --- /dev/null +++ b/packages/python-sdk/e2b/api/client_sync/__init__.py @@ -0,0 +1,73 @@ +from typing import Dict, Optional, Tuple + +import httpx +import threading + +from httpx._types import ProxyTypes + +from e2b.api import ApiClient, connection_retries, limits +from e2b.connection_config import ConnectionConfig + +TransportKey = Tuple[bool, Optional[ProxyTypes]] + + +def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient: + return ApiClient( + config, + transport_factory=lambda: get_transport(config), + **kwargs, + ) + + +class TransportWithLogger(httpx.HTTPTransport): + _thread_local = threading.local() + + @property + def pool(self): + return self._pool + + +def get_transport(config: ConnectionConfig, http2: bool = True) -> TransportWithLogger: + instances: Dict[TransportKey, TransportWithLogger] = getattr( + TransportWithLogger._thread_local, "instances", {} + ) + key: TransportKey = (http2, config.proxy) + cached = instances.get(key) + if cached is not None: + return cached + + transport = TransportWithLogger( + limits=limits, + proxy=config.proxy, + http2=http2, + retries=connection_retries, + ) + instances[key] = transport + TransportWithLogger._thread_local.instances = instances + return transport + + +class EnvdTransportWithLogger(TransportWithLogger): + _thread_local = threading.local() + + +def get_envd_transport( + config: ConnectionConfig, http2: bool = True +) -> EnvdTransportWithLogger: + instances: Dict[TransportKey, EnvdTransportWithLogger] = getattr( + EnvdTransportWithLogger._thread_local, "instances", {} + ) + key: TransportKey = (http2, config.proxy) + cached = instances.get(key) + if cached is not None: + return cached + + transport = EnvdTransportWithLogger( + limits=limits, + proxy=config.proxy, + http2=http2, + retries=connection_retries, + ) + instances[key] = transport + EnvdTransportWithLogger._thread_local.instances = instances + return transport diff --git a/packages/python-sdk/e2b/api/metadata.py b/packages/python-sdk/e2b/api/metadata.py new file mode 100644 index 0000000..c2247ce --- /dev/null +++ b/packages/python-sdk/e2b/api/metadata.py @@ -0,0 +1,14 @@ +import platform + +from importlib import metadata + +package_version = metadata.version("e2b") + +default_headers = { + "lang": "python", + "lang_version": platform.python_version(), + "package_version": metadata.version("e2b"), + "publisher": "e2b", + "sdk_runtime": "python", + "system": platform.system(), +} diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py new file mode 100644 index 0000000..81f4a09 --- /dev/null +++ b/packages/python-sdk/e2b/connection_config.py @@ -0,0 +1,344 @@ +import logging +import os + +from typing import cast, Optional, Dict, TypedDict + +from httpx._types import ProxyTypes +from typing_extensions import Unpack + +from e2b.api.metadata import package_version +from e2b.sandbox_domains import is_supported_sandbox_domain + +REQUEST_TIMEOUT: float = 60.0 # 60 seconds + +KEEPALIVE_PING_INTERVAL_SEC = 50 # 50 seconds +KEEPALIVE_PING_HEADER = "Keepalive-Ping-Interval" + + +class ApiParams(TypedDict, total=False): + """ + Parameters for a request. + + In the case of a sandbox, it applies to all **requests made to the returned sandbox**. + """ + + request_timeout: Optional[float] + """Timeout for the request in **seconds**, defaults to 60 seconds.""" + + headers: Optional[Dict[str, str]] + """Additional headers to send with the request. Deprecated, use api_headers instead.""" + + api_headers: Optional[Dict[str, str]] + """Additional headers to send with E2B API requests.""" + + api_key: Optional[str] + """E2B API Key to use for authentication, defaults to `E2B_API_KEY` environment variable.""" + + validate_api_key: Optional[bool] + """Whether to validate the format of the E2B API key on the client side. + Disable this when your deployment issues API keys that don't match the + default `e2b_` format. Defaults to `E2B_VALIDATE_API_KEY` environment + variable or `True`.""" + + domain: Optional[str] + """E2B domain to use for authentication, defaults to `E2B_DOMAIN` environment variable.""" + + api_url: Optional[str] + """URL to use for the API, defaults to `https://api.`. For internal use only.""" + + debug: Optional[bool] + """Whether to use debug mode, defaults to `E2B_DEBUG` environment variable.""" + + proxy: Optional[ProxyTypes] + """Proxy to use for the request. In case of a sandbox it applies to all **requests made to the returned sandbox**.""" + + sandbox_url: Optional[str] + """URL to connect to sandbox, defaults to `E2B_SANDBOX_URL` environment variable.""" + + +class ApiParamsWithLogger(ApiParams, total=False): + """:class:`ApiParams` plus the construction-time ``logger``. + + Internal type returned by :meth:`ConnectionConfig.get_api_params` so that the + logger a sandbox was created/connected with keeps propagating to the + throwaway ``ConnectionConfig`` that instance control-plane methods rebuild. + Unlike :class:`ApiParams`, ``logger`` is not a public per-request option. + """ + + logger: Optional[logging.Logger] + + +class ConnectionConfig: + """ + Configuration for the connection to the API. + """ + + envd_port = 49983 + + _integration: Optional[str] = None + + @classmethod + def set_integration(cls, integration: Optional[str]) -> None: + """ + Identify traffic from an integration wrapping the E2B SDK by appending + ``integration`` (e.g. ``"e2b-code-interpreter/0.1.0"``) to the + ``User-Agent`` header of every request. + + Call once at startup, before any ``ConnectionConfig`` is constructed — + configs read the value at construction time. Pass ``None`` to clear. + + Internal use only — not part of the public, stable API. + + :meta private: + """ + ConnectionConfig._integration = integration + + @staticmethod + def _domain(): + return os.getenv("E2B_DOMAIN") or "e2b.app" + + @staticmethod + def _debug(): + return os.getenv("E2B_DEBUG", "false").lower() == "true" + + @staticmethod + def _api_key(): + return os.getenv("E2B_API_KEY") + + @staticmethod + def _validate_api_key(): + return os.getenv("E2B_VALIDATE_API_KEY", "true").lower() != "false" + + @staticmethod + def _api_url(): + return os.getenv("E2B_API_URL") + + @staticmethod + def _sandbox_url(): + return os.getenv("E2B_SANDBOX_URL") + + @staticmethod + def _access_token(): + return os.getenv("E2B_ACCESS_TOKEN") + + @staticmethod + def _build_user_agent() -> str: + user_agent_parts = [f"e2b-python-sdk/{package_version}"] + + if ConnectionConfig._integration: + user_agent_parts.append(ConnectionConfig._integration) + + return " ".join(user_agent_parts) + + def _apply_user_agent( + self, + headers: Dict[str, str], + user_agent_override: Optional[str], + ) -> bool: + """ + Set the ``User-Agent`` on ``headers``: an explicitly provided value + always wins; otherwise the SDK-built one, tagged with the current + integration. + + Returns whether the SDK-built value was applied, so callers can keep + it in sync with the current integration on later rebuilds. + """ + if user_agent_override is not None: + headers["User-Agent"] = user_agent_override + return False + + headers["User-Agent"] = self._build_user_agent() + return True + + def __init__( + self, + domain: Optional[str] = None, + debug: Optional[bool] = None, + api_key: Optional[str] = None, + validate_api_key: Optional[bool] = None, + api_url: Optional[str] = None, + sandbox_url: Optional[str] = None, + access_token: Optional[str] = None, + request_timeout: Optional[float] = None, + headers: Optional[Dict[str, str]] = None, + api_headers: Optional[Dict[str, str]] = None, + extra_sandbox_headers: Optional[Dict[str, str]] = None, + proxy: Optional[ProxyTypes] = None, + logger: Optional[logging.Logger] = None, + ): + self.logger = logger + self.domain = domain or ConnectionConfig._domain() + self.debug = debug if debug is not None else ConnectionConfig._debug() + self.api_key = api_key or ConnectionConfig._api_key() + self.validate_api_key = ( + validate_api_key + if validate_api_key is not None + else ConnectionConfig._validate_api_key() + ) + # Deprecated: pass the token through `api_headers` instead, e.g. + # api_headers={"Authorization": f"Bearer {token}"}. + self.access_token = access_token or ConnectionConfig._access_token() + self.headers = {**(headers or {}), **(api_headers or {})} + self._user_agent_is_sdk_built = self._apply_user_agent( + self.headers, + self.headers.get("User-Agent"), + ) + self.__extra_sandbox_headers = extra_sandbox_headers or {} + + self.proxy = proxy + + self.request_timeout = ConnectionConfig._get_request_timeout( + REQUEST_TIMEOUT, + request_timeout, + ) + + self.api_url = ( + api_url + or ConnectionConfig._api_url() + or ("http://localhost:3000" if self.debug else f"https://api.{self.domain}") + ) + + self._sandbox_url: Optional[str] = ( + sandbox_url or ConnectionConfig._sandbox_url() + ) + + @staticmethod + def _get_request_timeout( + default_timeout: Optional[float], + request_timeout: Optional[float], + ): + if request_timeout == 0: + return None + elif request_timeout is not None: + return request_timeout + else: + return default_timeout + + def get_request_timeout(self, request_timeout: Optional[float] = None): + return self._get_request_timeout(self.request_timeout, request_timeout) + + def get_sandbox_url(self, sandbox_id: str, sandbox_domain: str) -> str: + if self._sandbox_url: + return self._sandbox_url # type: ignore[return-value] + + if self.debug: + return f"http://{self.get_host(sandbox_id, sandbox_domain, self.envd_port)}" + + sandbox_domain = sandbox_domain or self.domain + if is_supported_sandbox_domain(sandbox_domain): + return f"https://sandbox.{sandbox_domain}" + + return f"https://{self.get_host(sandbox_id, sandbox_domain, self.envd_port)}" + + def get_sandbox_direct_url(self, sandbox_id: str, sandbox_domain: str) -> str: + if self._sandbox_url: + return self._sandbox_url # type: ignore[return-value] + + if self.debug: + return f"http://{self.get_host(sandbox_id, sandbox_domain, self.envd_port)}" + + return f"https://{self.get_host(sandbox_id, sandbox_domain, self.envd_port)}" + + def get_host(self, sandbox_id: str, sandbox_domain: str, port: int) -> str: + """ + Get the host address to connect to the sandbox. + You can then use this address to connect to the sandbox port from outside the sandbox via HTTP or WebSocket. + + :param port: Port to connect to + :param sandbox_domain: Domain to connect to + :param sandbox_id: Sandbox to connect to + + :return: Host address to connect to + """ + if self.debug: + return f"localhost:{port}" + + return f"{port}-{sandbox_id}.{sandbox_domain}" + + def get_api_params( + self, + **opts: Unpack[ApiParams], + ) -> dict: + """ + Get the parameters for the API call. + + This is used to avoid passing the following attributes to the API call: + - access_token + - api_url + + It also returns a copy, so the original object is not modified. + + :return: Dictionary of parameters for the API call + """ + headers = opts.get("headers") + api_headers = opts.get("api_headers") + request_timeout = opts.get("request_timeout") + api_key = opts.get("api_key") + validate_api_key = opts.get("validate_api_key") + api_url = opts.get("api_url") + domain = opts.get("domain") + debug = opts.get("debug") + proxy = opts.get("proxy") + sandbox_url = opts.get("sandbox_url") + + req_headers = self.headers.copy() + if headers is not None: + req_headers.update(headers) + if api_headers is not None: + req_headers.update(api_headers) + if self._user_agent_is_sdk_built: + # Same precedence as the merge above: api_headers wins over headers. + per_call_user_agent = {**(headers or {}), **(api_headers or {})}.get( + "User-Agent" + ) + self._apply_user_agent(req_headers, per_call_user_agent) + + # `logger` is a construction-time option rather than a per-request + # ApiParams field, but it must propagate to the throwaway + # ConnectionConfig that instance control-plane methods (kill, pause, + # set_timeout, get_info, connect, ...) rebuild from these params, so + # those requests keep logging with the logger the sandbox was created + # or connected with. + return dict( + ApiParamsWithLogger( + api_key=api_key if api_key is not None else self.api_key, + validate_api_key=( + validate_api_key + if validate_api_key is not None + else self.validate_api_key + ), + api_url=api_url if api_url is not None else self.api_url, + domain=domain if domain is not None else self.domain, + debug=debug if debug is not None else self.debug, + request_timeout=self.get_request_timeout(request_timeout), + headers=req_headers, + proxy=proxy if proxy is not None else self.proxy, + sandbox_url=( + sandbox_url + if sandbox_url is not None + else cast(Optional[str], self._sandbox_url) + ), + logger=self.logger, + ) + ) + + @property + def sandbox_headers(self): + """ + We need this separate as we use the same header for E2B access token to API and envd access token to sandbox. + """ + return { + "User-Agent": self.headers["User-Agent"], + **self.__extra_sandbox_headers, + } + + +Username = str +""" +User used for the operation in the sandbox. +""" + +default_username: Username = "user" +""" +Default user used for the operation in the sandbox. +""" diff --git a/packages/python-sdk/e2b/envd/api.py b/packages/python-sdk/e2b/envd/api.py new file mode 100644 index 0000000..811d58d --- /dev/null +++ b/packages/python-sdk/e2b/envd/api.py @@ -0,0 +1,170 @@ +import httpx +import json + +from typing import Callable, Optional + +from e2b.envd.rpc import format_terminated_exception +from e2b.exceptions import ( + SandboxException, + NotFoundException, + AuthenticationException, + InvalidArgumentException, + NotEnoughSpaceException, + RateLimitException, + format_sandbox_timeout_exception, +) + + +ENVD_API_FILES_ROUTE = "/files" +ENVD_API_HEALTH_ROUTE = "/health" + +_DEFAULT_API_ERROR_MAP: dict[int, Callable[[str], Exception]] = { + 400: InvalidArgumentException, + 401: AuthenticationException, + 404: NotFoundException, + 429: lambda message: RateLimitException( + f"{message}: The requests are being rate limited." + ), + 502: format_sandbox_timeout_exception, + 507: NotEnoughSpaceException, +} + + +HEALTH_CHECK_TIMEOUT = 5 # seconds + + +def check_sandbox_health(envd_api: httpx.Client) -> Optional[bool]: + """Probe the sandbox's envd health endpoint. + + :return: ``True`` if the sandbox is running, ``False`` if it is not, ``None`` if its state could not be determined. + """ + try: + r = envd_api.get(ENVD_API_HEALTH_ROUTE, timeout=HEALTH_CHECK_TIMEOUT) + if r.status_code == 502: + return False + if r.is_success: + return True + return None + except Exception: + return None + + +async def acheck_sandbox_health(envd_api: httpx.AsyncClient) -> Optional[bool]: + """Async version of :func:`check_sandbox_health`.""" + try: + r = await envd_api.get(ENVD_API_HEALTH_ROUTE, timeout=HEALTH_CHECK_TIMEOUT) + if r.status_code == 502: + return False + if r.is_success: + return True + return None + except Exception: + return None + + +def handle_envd_api_transport_exception( + e: Exception, + sandbox_running: Optional[bool] = None, +) -> Exception: + """Handle transport-level errors from envd API requests. + + :param e: The caught exception, expected to be a transport-level ``httpx`` error. + :param sandbox_running: Result of a sandbox health probe (``None`` when unknown), used to disambiguate a connection dropped mid-request. + :return: A ``TimeoutException`` when the connection dropped mid-request and the sandbox is confirmed gone, or the original exception unchanged otherwise. + """ + # A remote protocol error (e.g. an HTTP/2 stream reset) means the connection to the + # sandbox was dropped mid-request — either the sandbox died or the network failed + if isinstance(e, httpx.RemoteProtocolError): + return format_terminated_exception(e, sandbox_running) + + return e + + +def handle_envd_api_transport_exception_with_health( + e: Exception, + envd_api: httpx.Client, +) -> Exception: + """Like :func:`handle_envd_api_transport_exception`, but when the connection to the + sandbox was dropped mid-request it probes the sandbox health to tell apart the sandbox + being killed from a transient network failure (e.g. a load balancer dropping the connection). + """ + sandbox_running = ( + check_sandbox_health(envd_api) + if isinstance(e, httpx.RemoteProtocolError) + else None + ) + return handle_envd_api_transport_exception(e, sandbox_running) + + +async def ahandle_envd_api_transport_exception_with_health( + e: Exception, + envd_api: httpx.AsyncClient, +) -> Exception: + """Async version of :func:`handle_envd_api_transport_exception_with_health`.""" + sandbox_running = ( + await acheck_sandbox_health(envd_api) + if isinstance(e, httpx.RemoteProtocolError) + else None + ) + return handle_envd_api_transport_exception(e, sandbox_running) + + +def get_message(e: httpx.Response) -> str: + try: + message = e.json().get("message", e.text) + except json.JSONDecodeError: + message = e.text + + return message + + +def handle_envd_api_exception( + res: httpx.Response, + error_map: Optional[dict[int, Callable[[str], Exception]]] = None, +): + """Handle errors from envd API responses by mapping HTTP status codes to specific exception types. + + :param res: The HTTP response. + :param error_map: Optional map of HTTP status codes to exception factories that override the defaults. + :return: The corresponding exception, or ``None`` if the response is successful. + """ + if res.is_success: + return + + res.read() + + return format_envd_api_exception(res.status_code, get_message(res), error_map) + + +async def ahandle_envd_api_exception( + res: httpx.Response, + error_map: Optional[dict[int, Callable[[str], Exception]]] = None, +): + """Async version of :func:`handle_envd_api_exception`.""" + if res.is_success: + return + + await res.aread() + + return format_envd_api_exception(res.status_code, get_message(res), error_map) + + +def format_envd_api_exception( + status_code: int, + message: str, + error_map: Optional[dict[int, Callable[[str], Exception]]] = None, +): + """Map an HTTP status code and message to the appropriate exception. + + :param status_code: The HTTP status code. + :param message: The error message from the response body. + :param error_map: Optional map of HTTP status codes to exception factories that override the defaults. + :return: The corresponding exception. + """ + if error_map and status_code in error_map: + return error_map[status_code](message) + + if status_code in _DEFAULT_API_ERROR_MAP: + return _DEFAULT_API_ERROR_MAP[status_code](message) + + return SandboxException(f"{status_code}: {message}") diff --git a/packages/python-sdk/e2b/envd/filesystem/filesystem_connect.py b/packages/python-sdk/e2b/envd/filesystem/filesystem_connect.py new file mode 100644 index 0000000..e995585 --- /dev/null +++ b/packages/python-sdk/e2b/envd/filesystem/filesystem_connect.py @@ -0,0 +1,193 @@ +# Code generated by protoc-gen-connect-python 0.1.0.dev2, DO NOT EDIT. +from typing import Any, Generator, Coroutine, AsyncGenerator, Optional +from httpcore import ConnectionPool, AsyncConnectionPool + +import e2b_connect as connect + +from e2b.envd.filesystem import filesystem_pb2 as filesystem_dot_filesystem__pb2 + +FilesystemName = "filesystem.Filesystem" + + +class FilesystemClient: + def __init__( + self, + base_url: str, + *, + pool: Optional[ConnectionPool] = None, + async_pool: Optional[AsyncConnectionPool] = None, + compressor=None, + json=False, + **opts, + ): + self._stat = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/Stat", + response_type=filesystem_dot_filesystem__pb2.StatResponse, + compressor=compressor, + json=json, + **opts, + ) + self._make_dir = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/MakeDir", + response_type=filesystem_dot_filesystem__pb2.MakeDirResponse, + compressor=compressor, + json=json, + **opts, + ) + self._move = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/Move", + response_type=filesystem_dot_filesystem__pb2.MoveResponse, + compressor=compressor, + json=json, + **opts, + ) + self._list_dir = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/ListDir", + response_type=filesystem_dot_filesystem__pb2.ListDirResponse, + compressor=compressor, + json=json, + **opts, + ) + self._remove = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/Remove", + response_type=filesystem_dot_filesystem__pb2.RemoveResponse, + compressor=compressor, + json=json, + **opts, + ) + self._watch_dir = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/WatchDir", + response_type=filesystem_dot_filesystem__pb2.WatchDirResponse, + compressor=compressor, + json=json, + **opts, + ) + self._create_watcher = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/CreateWatcher", + response_type=filesystem_dot_filesystem__pb2.CreateWatcherResponse, + compressor=compressor, + json=json, + **opts, + ) + self._get_watcher_events = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/GetWatcherEvents", + response_type=filesystem_dot_filesystem__pb2.GetWatcherEventsResponse, + compressor=compressor, + json=json, + **opts, + ) + self._remove_watcher = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/RemoveWatcher", + response_type=filesystem_dot_filesystem__pb2.RemoveWatcherResponse, + compressor=compressor, + json=json, + **opts, + ) + + def stat( + self, req: filesystem_dot_filesystem__pb2.StatRequest, **opts + ) -> filesystem_dot_filesystem__pb2.StatResponse: + return self._stat.call_unary(req, **opts) + + def astat( + self, req: filesystem_dot_filesystem__pb2.StatRequest, **opts + ) -> Coroutine[Any, Any, filesystem_dot_filesystem__pb2.StatResponse]: + return self._stat.acall_unary(req, **opts) + + def make_dir( + self, req: filesystem_dot_filesystem__pb2.MakeDirRequest, **opts + ) -> filesystem_dot_filesystem__pb2.MakeDirResponse: + return self._make_dir.call_unary(req, **opts) + + def amake_dir( + self, req: filesystem_dot_filesystem__pb2.MakeDirRequest, **opts + ) -> Coroutine[Any, Any, filesystem_dot_filesystem__pb2.MakeDirResponse]: + return self._make_dir.acall_unary(req, **opts) + + def move( + self, req: filesystem_dot_filesystem__pb2.MoveRequest, **opts + ) -> filesystem_dot_filesystem__pb2.MoveResponse: + return self._move.call_unary(req, **opts) + + def amove( + self, req: filesystem_dot_filesystem__pb2.MoveRequest, **opts + ) -> Coroutine[Any, Any, filesystem_dot_filesystem__pb2.MoveResponse]: + return self._move.acall_unary(req, **opts) + + def list_dir( + self, req: filesystem_dot_filesystem__pb2.ListDirRequest, **opts + ) -> filesystem_dot_filesystem__pb2.ListDirResponse: + return self._list_dir.call_unary(req, **opts) + + def alist_dir( + self, req: filesystem_dot_filesystem__pb2.ListDirRequest, **opts + ) -> Coroutine[Any, Any, filesystem_dot_filesystem__pb2.ListDirResponse]: + return self._list_dir.acall_unary(req, **opts) + + def remove( + self, req: filesystem_dot_filesystem__pb2.RemoveRequest, **opts + ) -> filesystem_dot_filesystem__pb2.RemoveResponse: + return self._remove.call_unary(req, **opts) + + def aremove( + self, req: filesystem_dot_filesystem__pb2.RemoveRequest, **opts + ) -> Coroutine[Any, Any, filesystem_dot_filesystem__pb2.RemoveResponse]: + return self._remove.acall_unary(req, **opts) + + def watch_dir( + self, req: filesystem_dot_filesystem__pb2.WatchDirRequest, **opts + ) -> Generator[filesystem_dot_filesystem__pb2.WatchDirResponse, Any, None]: + return self._watch_dir.call_server_stream(req, **opts) + + def awatch_dir( + self, req: filesystem_dot_filesystem__pb2.WatchDirRequest, **opts + ) -> AsyncGenerator[filesystem_dot_filesystem__pb2.WatchDirResponse, Any]: + return self._watch_dir.acall_server_stream(req, **opts) + + def create_watcher( + self, req: filesystem_dot_filesystem__pb2.CreateWatcherRequest, **opts + ) -> filesystem_dot_filesystem__pb2.CreateWatcherResponse: + return self._create_watcher.call_unary(req, **opts) + + def acreate_watcher( + self, req: filesystem_dot_filesystem__pb2.CreateWatcherRequest, **opts + ) -> Coroutine[Any, Any, filesystem_dot_filesystem__pb2.CreateWatcherResponse]: + return self._create_watcher.acall_unary(req, **opts) + + def get_watcher_events( + self, req: filesystem_dot_filesystem__pb2.GetWatcherEventsRequest, **opts + ) -> filesystem_dot_filesystem__pb2.GetWatcherEventsResponse: + return self._get_watcher_events.call_unary(req, **opts) + + def aget_watcher_events( + self, req: filesystem_dot_filesystem__pb2.GetWatcherEventsRequest, **opts + ) -> Coroutine[Any, Any, filesystem_dot_filesystem__pb2.GetWatcherEventsResponse]: + return self._get_watcher_events.acall_unary(req, **opts) + + def remove_watcher( + self, req: filesystem_dot_filesystem__pb2.RemoveWatcherRequest, **opts + ) -> filesystem_dot_filesystem__pb2.RemoveWatcherResponse: + return self._remove_watcher.call_unary(req, **opts) + + def aremove_watcher( + self, req: filesystem_dot_filesystem__pb2.RemoveWatcherRequest, **opts + ) -> Coroutine[Any, Any, filesystem_dot_filesystem__pb2.RemoveWatcherResponse]: + return self._remove_watcher.acall_unary(req, **opts) diff --git a/packages/python-sdk/e2b/envd/filesystem/filesystem_pb2.py b/packages/python-sdk/e2b/envd/filesystem/filesystem_pb2.py new file mode 100644 index 0000000..f2a3796 --- /dev/null +++ b/packages/python-sdk/e2b/envd/filesystem/filesystem_pb2.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: filesystem/filesystem.proto +# Protobuf Python Version: 5.26.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1b\x66ilesystem/filesystem.proto\x12\nfilesystem\x1a\x1fgoogle/protobuf/timestamp.proto\"G\n\x0bMoveRequest\x12\x16\n\x06source\x18\x01 \x01(\tR\x06source\x12 \n\x0b\x64\x65stination\x18\x02 \x01(\tR\x0b\x64\x65stination\";\n\x0cMoveResponse\x12+\n\x05\x65ntry\x18\x01 \x01(\x0b\x32\x15.filesystem.EntryInfoR\x05\x65ntry\"$\n\x0eMakeDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\">\n\x0fMakeDirResponse\x12+\n\x05\x65ntry\x18\x01 \x01(\x0b\x32\x15.filesystem.EntryInfoR\x05\x65ntry\"#\n\rRemoveRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\"\x10\n\x0eRemoveResponse\"!\n\x0bStatRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\";\n\x0cStatResponse\x12+\n\x05\x65ntry\x18\x01 \x01(\x0b\x32\x15.filesystem.EntryInfoR\x05\x65ntry\"\xd1\x03\n\tEntryInfo\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12(\n\x04type\x18\x02 \x01(\x0e\x32\x14.filesystem.FileTypeR\x04type\x12\x12\n\x04path\x18\x03 \x01(\tR\x04path\x12\x12\n\x04size\x18\x04 \x01(\x03R\x04size\x12\x12\n\x04mode\x18\x05 \x01(\rR\x04mode\x12 \n\x0bpermissions\x18\x06 \x01(\tR\x0bpermissions\x12\x14\n\x05owner\x18\x07 \x01(\tR\x05owner\x12\x14\n\x05group\x18\x08 \x01(\tR\x05group\x12?\n\rmodified_time\x18\t \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x0cmodifiedTime\x12*\n\x0esymlink_target\x18\n \x01(\tH\x00R\rsymlinkTarget\x88\x01\x01\x12?\n\x08metadata\x18\x0b \x03(\x0b\x32#.filesystem.EntryInfo.MetadataEntryR\x08metadata\x1a;\n\rMetadataEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x11\n\x0f_symlink_target\":\n\x0eListDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x14\n\x05\x64\x65pth\x18\x02 \x01(\rR\x05\x64\x65pth\"B\n\x0fListDirResponse\x12/\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x15.filesystem.EntryInfoR\x07\x65ntries\"\x9a\x01\n\x0fWatchDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n\trecursive\x18\x02 \x01(\x08R\trecursive\x12#\n\rinclude_entry\x18\x03 \x01(\x08R\x0cincludeEntry\x12\x30\n\x14\x61llow_network_mounts\x18\x04 \x01(\x08R\x12\x61llowNetworkMounts\"\x8c\x01\n\x0f\x46ilesystemEvent\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12)\n\x04type\x18\x02 \x01(\x0e\x32\x15.filesystem.EventTypeR\x04type\x12\x30\n\x05\x65ntry\x18\x03 \x01(\x0b\x32\x15.filesystem.EntryInfoH\x00R\x05\x65ntry\x88\x01\x01\x42\x08\n\x06_entry\"\xfe\x01\n\x10WatchDirResponse\x12?\n\x05start\x18\x01 \x01(\x0b\x32\'.filesystem.WatchDirResponse.StartEventH\x00R\x05start\x12=\n\nfilesystem\x18\x02 \x01(\x0b\x32\x1b.filesystem.FilesystemEventH\x00R\nfilesystem\x12\x46\n\tkeepalive\x18\x03 \x01(\x0b\x32&.filesystem.WatchDirResponse.KeepAliveH\x00R\tkeepalive\x1a\x0c\n\nStartEvent\x1a\x0b\n\tKeepAliveB\x07\n\x05\x65vent\"\x9f\x01\n\x14\x43reateWatcherRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n\trecursive\x18\x02 \x01(\x08R\trecursive\x12#\n\rinclude_entry\x18\x03 \x01(\x08R\x0cincludeEntry\x12\x30\n\x14\x61llow_network_mounts\x18\x04 \x01(\x08R\x12\x61llowNetworkMounts\"6\n\x15\x43reateWatcherResponse\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId\"8\n\x17GetWatcherEventsRequest\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId\"O\n\x18GetWatcherEventsResponse\x12\x33\n\x06\x65vents\x18\x01 \x03(\x0b\x32\x1b.filesystem.FilesystemEventR\x06\x65vents\"5\n\x14RemoveWatcherRequest\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId\"\x17\n\x15RemoveWatcherResponse*R\n\x08\x46ileType\x12\x19\n\x15\x46ILE_TYPE_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x46ILE_TYPE_FILE\x10\x01\x12\x17\n\x13\x46ILE_TYPE_DIRECTORY\x10\x02*\x98\x01\n\tEventType\x12\x1a\n\x16\x45VENT_TYPE_UNSPECIFIED\x10\x00\x12\x15\n\x11\x45VENT_TYPE_CREATE\x10\x01\x12\x14\n\x10\x45VENT_TYPE_WRITE\x10\x02\x12\x15\n\x11\x45VENT_TYPE_REMOVE\x10\x03\x12\x15\n\x11\x45VENT_TYPE_RENAME\x10\x04\x12\x14\n\x10\x45VENT_TYPE_CHMOD\x10\x05\x32\x9f\x05\n\nFilesystem\x12\x39\n\x04Stat\x12\x17.filesystem.StatRequest\x1a\x18.filesystem.StatResponse\x12\x42\n\x07MakeDir\x12\x1a.filesystem.MakeDirRequest\x1a\x1b.filesystem.MakeDirResponse\x12\x39\n\x04Move\x12\x17.filesystem.MoveRequest\x1a\x18.filesystem.MoveResponse\x12\x42\n\x07ListDir\x12\x1a.filesystem.ListDirRequest\x1a\x1b.filesystem.ListDirResponse\x12?\n\x06Remove\x12\x19.filesystem.RemoveRequest\x1a\x1a.filesystem.RemoveResponse\x12G\n\x08WatchDir\x12\x1b.filesystem.WatchDirRequest\x1a\x1c.filesystem.WatchDirResponse0\x01\x12T\n\rCreateWatcher\x12 .filesystem.CreateWatcherRequest\x1a!.filesystem.CreateWatcherResponse\x12]\n\x10GetWatcherEvents\x12#.filesystem.GetWatcherEventsRequest\x1a$.filesystem.GetWatcherEventsResponse\x12T\n\rRemoveWatcher\x12 .filesystem.RemoveWatcherRequest\x1a!.filesystem.RemoveWatcherResponseBi\n\x0e\x63om.filesystemB\x0f\x46ilesystemProtoP\x01\xa2\x02\x03\x46XX\xaa\x02\nFilesystem\xca\x02\nFilesystem\xe2\x02\x16\x46ilesystem\\GPBMetadata\xea\x02\nFilesystemb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'filesystem.filesystem_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'\n\016com.filesystemB\017FilesystemProtoP\001\242\002\003FXX\252\002\nFilesystem\312\002\nFilesystem\342\002\026Filesystem\\GPBMetadata\352\002\nFilesystem' + _globals['_ENTRYINFO_METADATAENTRY']._loaded_options = None + _globals['_ENTRYINFO_METADATAENTRY']._serialized_options = b'8\001' + _globals['_FILETYPE']._serialized_start=2053 + _globals['_FILETYPE']._serialized_end=2135 + _globals['_EVENTTYPE']._serialized_start=2138 + _globals['_EVENTTYPE']._serialized_end=2290 + _globals['_MOVEREQUEST']._serialized_start=76 + _globals['_MOVEREQUEST']._serialized_end=147 + _globals['_MOVERESPONSE']._serialized_start=149 + _globals['_MOVERESPONSE']._serialized_end=208 + _globals['_MAKEDIRREQUEST']._serialized_start=210 + _globals['_MAKEDIRREQUEST']._serialized_end=246 + _globals['_MAKEDIRRESPONSE']._serialized_start=248 + _globals['_MAKEDIRRESPONSE']._serialized_end=310 + _globals['_REMOVEREQUEST']._serialized_start=312 + _globals['_REMOVEREQUEST']._serialized_end=347 + _globals['_REMOVERESPONSE']._serialized_start=349 + _globals['_REMOVERESPONSE']._serialized_end=365 + _globals['_STATREQUEST']._serialized_start=367 + _globals['_STATREQUEST']._serialized_end=400 + _globals['_STATRESPONSE']._serialized_start=402 + _globals['_STATRESPONSE']._serialized_end=461 + _globals['_ENTRYINFO']._serialized_start=464 + _globals['_ENTRYINFO']._serialized_end=929 + _globals['_ENTRYINFO_METADATAENTRY']._serialized_start=851 + _globals['_ENTRYINFO_METADATAENTRY']._serialized_end=910 + _globals['_LISTDIRREQUEST']._serialized_start=931 + _globals['_LISTDIRREQUEST']._serialized_end=989 + _globals['_LISTDIRRESPONSE']._serialized_start=991 + _globals['_LISTDIRRESPONSE']._serialized_end=1057 + _globals['_WATCHDIRREQUEST']._serialized_start=1060 + _globals['_WATCHDIRREQUEST']._serialized_end=1214 + _globals['_FILESYSTEMEVENT']._serialized_start=1217 + _globals['_FILESYSTEMEVENT']._serialized_end=1357 + _globals['_WATCHDIRRESPONSE']._serialized_start=1360 + _globals['_WATCHDIRRESPONSE']._serialized_end=1614 + _globals['_WATCHDIRRESPONSE_STARTEVENT']._serialized_start=1580 + _globals['_WATCHDIRRESPONSE_STARTEVENT']._serialized_end=1592 + _globals['_WATCHDIRRESPONSE_KEEPALIVE']._serialized_start=1594 + _globals['_WATCHDIRRESPONSE_KEEPALIVE']._serialized_end=1605 + _globals['_CREATEWATCHERREQUEST']._serialized_start=1617 + _globals['_CREATEWATCHERREQUEST']._serialized_end=1776 + _globals['_CREATEWATCHERRESPONSE']._serialized_start=1778 + _globals['_CREATEWATCHERRESPONSE']._serialized_end=1832 + _globals['_GETWATCHEREVENTSREQUEST']._serialized_start=1834 + _globals['_GETWATCHEREVENTSREQUEST']._serialized_end=1890 + _globals['_GETWATCHEREVENTSRESPONSE']._serialized_start=1892 + _globals['_GETWATCHEREVENTSRESPONSE']._serialized_end=1971 + _globals['_REMOVEWATCHERREQUEST']._serialized_start=1973 + _globals['_REMOVEWATCHERREQUEST']._serialized_end=2026 + _globals['_REMOVEWATCHERRESPONSE']._serialized_start=2028 + _globals['_REMOVEWATCHERRESPONSE']._serialized_end=2051 + _globals['_FILESYSTEM']._serialized_start=2293 + _globals['_FILESYSTEM']._serialized_end=2964 +# @@protoc_insertion_point(module_scope) diff --git a/packages/python-sdk/e2b/envd/filesystem/filesystem_pb2.pyi b/packages/python-sdk/e2b/envd/filesystem/filesystem_pb2.pyi new file mode 100644 index 0000000..73ede13 --- /dev/null +++ b/packages/python-sdk/e2b/envd/filesystem/filesystem_pb2.pyi @@ -0,0 +1,272 @@ +from google.protobuf import timestamp_pb2 as _timestamp_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ( + ClassVar as _ClassVar, + Iterable as _Iterable, + Mapping as _Mapping, + Optional as _Optional, + Union as _Union, +) + +DESCRIPTOR: _descriptor.FileDescriptor + +class FileType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + FILE_TYPE_UNSPECIFIED: _ClassVar[FileType] + FILE_TYPE_FILE: _ClassVar[FileType] + FILE_TYPE_DIRECTORY: _ClassVar[FileType] + +class EventType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + EVENT_TYPE_UNSPECIFIED: _ClassVar[EventType] + EVENT_TYPE_CREATE: _ClassVar[EventType] + EVENT_TYPE_WRITE: _ClassVar[EventType] + EVENT_TYPE_REMOVE: _ClassVar[EventType] + EVENT_TYPE_RENAME: _ClassVar[EventType] + EVENT_TYPE_CHMOD: _ClassVar[EventType] + +FILE_TYPE_UNSPECIFIED: FileType +FILE_TYPE_FILE: FileType +FILE_TYPE_DIRECTORY: FileType +EVENT_TYPE_UNSPECIFIED: EventType +EVENT_TYPE_CREATE: EventType +EVENT_TYPE_WRITE: EventType +EVENT_TYPE_REMOVE: EventType +EVENT_TYPE_RENAME: EventType +EVENT_TYPE_CHMOD: EventType + +class MoveRequest(_message.Message): + __slots__ = ("source", "destination") + SOURCE_FIELD_NUMBER: _ClassVar[int] + DESTINATION_FIELD_NUMBER: _ClassVar[int] + source: str + destination: str + def __init__( + self, source: _Optional[str] = ..., destination: _Optional[str] = ... + ) -> None: ... + +class MoveResponse(_message.Message): + __slots__ = ("entry",) + ENTRY_FIELD_NUMBER: _ClassVar[int] + entry: EntryInfo + def __init__(self, entry: _Optional[_Union[EntryInfo, _Mapping]] = ...) -> None: ... + +class MakeDirRequest(_message.Message): + __slots__ = ("path",) + PATH_FIELD_NUMBER: _ClassVar[int] + path: str + def __init__(self, path: _Optional[str] = ...) -> None: ... + +class MakeDirResponse(_message.Message): + __slots__ = ("entry",) + ENTRY_FIELD_NUMBER: _ClassVar[int] + entry: EntryInfo + def __init__(self, entry: _Optional[_Union[EntryInfo, _Mapping]] = ...) -> None: ... + +class RemoveRequest(_message.Message): + __slots__ = ("path",) + PATH_FIELD_NUMBER: _ClassVar[int] + path: str + def __init__(self, path: _Optional[str] = ...) -> None: ... + +class RemoveResponse(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class StatRequest(_message.Message): + __slots__ = ("path",) + PATH_FIELD_NUMBER: _ClassVar[int] + path: str + def __init__(self, path: _Optional[str] = ...) -> None: ... + +class StatResponse(_message.Message): + __slots__ = ("entry",) + ENTRY_FIELD_NUMBER: _ClassVar[int] + entry: EntryInfo + def __init__(self, entry: _Optional[_Union[EntryInfo, _Mapping]] = ...) -> None: ... + +class EntryInfo(_message.Message): + __slots__ = ( + "name", + "type", + "path", + "size", + "mode", + "permissions", + "owner", + "group", + "modified_time", + "symlink_target", + "metadata", + ) + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__( + self, key: _Optional[str] = ..., value: _Optional[str] = ... + ) -> None: ... + + NAME_FIELD_NUMBER: _ClassVar[int] + TYPE_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + SIZE_FIELD_NUMBER: _ClassVar[int] + MODE_FIELD_NUMBER: _ClassVar[int] + PERMISSIONS_FIELD_NUMBER: _ClassVar[int] + OWNER_FIELD_NUMBER: _ClassVar[int] + GROUP_FIELD_NUMBER: _ClassVar[int] + MODIFIED_TIME_FIELD_NUMBER: _ClassVar[int] + SYMLINK_TARGET_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] + name: str + type: FileType + path: str + size: int + mode: int + permissions: str + owner: str + group: str + modified_time: _timestamp_pb2.Timestamp + symlink_target: str + metadata: _containers.ScalarMap[str, str] + def __init__( + self, + name: _Optional[str] = ..., + type: _Optional[_Union[FileType, str]] = ..., + path: _Optional[str] = ..., + size: _Optional[int] = ..., + mode: _Optional[int] = ..., + permissions: _Optional[str] = ..., + owner: _Optional[str] = ..., + group: _Optional[str] = ..., + modified_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., + symlink_target: _Optional[str] = ..., + metadata: _Optional[_Mapping[str, str]] = ..., + ) -> None: ... + +class ListDirRequest(_message.Message): + __slots__ = ("path", "depth") + PATH_FIELD_NUMBER: _ClassVar[int] + DEPTH_FIELD_NUMBER: _ClassVar[int] + path: str + depth: int + def __init__( + self, path: _Optional[str] = ..., depth: _Optional[int] = ... + ) -> None: ... + +class ListDirResponse(_message.Message): + __slots__ = ("entries",) + ENTRIES_FIELD_NUMBER: _ClassVar[int] + entries: _containers.RepeatedCompositeFieldContainer[EntryInfo] + def __init__( + self, entries: _Optional[_Iterable[_Union[EntryInfo, _Mapping]]] = ... + ) -> None: ... + +class WatchDirRequest(_message.Message): + __slots__ = ("path", "recursive", "include_entry", "allow_network_mounts") + PATH_FIELD_NUMBER: _ClassVar[int] + RECURSIVE_FIELD_NUMBER: _ClassVar[int] + INCLUDE_ENTRY_FIELD_NUMBER: _ClassVar[int] + ALLOW_NETWORK_MOUNTS_FIELD_NUMBER: _ClassVar[int] + path: str + recursive: bool + include_entry: bool + allow_network_mounts: bool + def __init__( + self, + path: _Optional[str] = ..., + recursive: bool = ..., + include_entry: bool = ..., + allow_network_mounts: bool = ..., + ) -> None: ... + +class FilesystemEvent(_message.Message): + __slots__ = ("name", "type", "entry") + NAME_FIELD_NUMBER: _ClassVar[int] + TYPE_FIELD_NUMBER: _ClassVar[int] + ENTRY_FIELD_NUMBER: _ClassVar[int] + name: str + type: EventType + entry: EntryInfo + def __init__( + self, + name: _Optional[str] = ..., + type: _Optional[_Union[EventType, str]] = ..., + entry: _Optional[_Union[EntryInfo, _Mapping]] = ..., + ) -> None: ... + +class WatchDirResponse(_message.Message): + __slots__ = ("start", "filesystem", "keepalive") + class StartEvent(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + + class KeepAlive(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + + START_FIELD_NUMBER: _ClassVar[int] + FILESYSTEM_FIELD_NUMBER: _ClassVar[int] + KEEPALIVE_FIELD_NUMBER: _ClassVar[int] + start: WatchDirResponse.StartEvent + filesystem: FilesystemEvent + keepalive: WatchDirResponse.KeepAlive + def __init__( + self, + start: _Optional[_Union[WatchDirResponse.StartEvent, _Mapping]] = ..., + filesystem: _Optional[_Union[FilesystemEvent, _Mapping]] = ..., + keepalive: _Optional[_Union[WatchDirResponse.KeepAlive, _Mapping]] = ..., + ) -> None: ... + +class CreateWatcherRequest(_message.Message): + __slots__ = ("path", "recursive", "include_entry", "allow_network_mounts") + PATH_FIELD_NUMBER: _ClassVar[int] + RECURSIVE_FIELD_NUMBER: _ClassVar[int] + INCLUDE_ENTRY_FIELD_NUMBER: _ClassVar[int] + ALLOW_NETWORK_MOUNTS_FIELD_NUMBER: _ClassVar[int] + path: str + recursive: bool + include_entry: bool + allow_network_mounts: bool + def __init__( + self, + path: _Optional[str] = ..., + recursive: bool = ..., + include_entry: bool = ..., + allow_network_mounts: bool = ..., + ) -> None: ... + +class CreateWatcherResponse(_message.Message): + __slots__ = ("watcher_id",) + WATCHER_ID_FIELD_NUMBER: _ClassVar[int] + watcher_id: str + def __init__(self, watcher_id: _Optional[str] = ...) -> None: ... + +class GetWatcherEventsRequest(_message.Message): + __slots__ = ("watcher_id",) + WATCHER_ID_FIELD_NUMBER: _ClassVar[int] + watcher_id: str + def __init__(self, watcher_id: _Optional[str] = ...) -> None: ... + +class GetWatcherEventsResponse(_message.Message): + __slots__ = ("events",) + EVENTS_FIELD_NUMBER: _ClassVar[int] + events: _containers.RepeatedCompositeFieldContainer[FilesystemEvent] + def __init__( + self, events: _Optional[_Iterable[_Union[FilesystemEvent, _Mapping]]] = ... + ) -> None: ... + +class RemoveWatcherRequest(_message.Message): + __slots__ = ("watcher_id",) + WATCHER_ID_FIELD_NUMBER: _ClassVar[int] + watcher_id: str + def __init__(self, watcher_id: _Optional[str] = ...) -> None: ... + +class RemoveWatcherResponse(_message.Message): + __slots__ = () + def __init__(self) -> None: ... diff --git a/packages/python-sdk/e2b/envd/process/process_connect.py b/packages/python-sdk/e2b/envd/process/process_connect.py new file mode 100644 index 0000000..dc09fbe --- /dev/null +++ b/packages/python-sdk/e2b/envd/process/process_connect.py @@ -0,0 +1,174 @@ +# Code generated by protoc-gen-connect-python 0.1.0.dev2, DO NOT EDIT. +from typing import Any, Generator, Coroutine, AsyncGenerator, Optional +from httpcore import ConnectionPool, AsyncConnectionPool + +import e2b_connect as connect + +from e2b.envd.process import process_pb2 as process_dot_process__pb2 + +ProcessName = "process.Process" + + +class ProcessClient: + def __init__( + self, + base_url: str, + *, + pool: Optional[ConnectionPool] = None, + async_pool: Optional[AsyncConnectionPool] = None, + compressor=None, + json=False, + **opts, + ): + self._list = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{ProcessName}/List", + response_type=process_dot_process__pb2.ListResponse, + compressor=compressor, + json=json, + **opts, + ) + self._connect = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{ProcessName}/Connect", + response_type=process_dot_process__pb2.ConnectResponse, + compressor=compressor, + json=json, + **opts, + ) + self._start = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{ProcessName}/Start", + response_type=process_dot_process__pb2.StartResponse, + compressor=compressor, + json=json, + **opts, + ) + self._update = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{ProcessName}/Update", + response_type=process_dot_process__pb2.UpdateResponse, + compressor=compressor, + json=json, + **opts, + ) + self._stream_input = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{ProcessName}/StreamInput", + response_type=process_dot_process__pb2.StreamInputResponse, + compressor=compressor, + json=json, + **opts, + ) + self._send_input = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{ProcessName}/SendInput", + response_type=process_dot_process__pb2.SendInputResponse, + compressor=compressor, + json=json, + **opts, + ) + self._send_signal = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{ProcessName}/SendSignal", + response_type=process_dot_process__pb2.SendSignalResponse, + compressor=compressor, + json=json, + **opts, + ) + self._close_stdin = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{ProcessName}/CloseStdin", + response_type=process_dot_process__pb2.CloseStdinResponse, + compressor=compressor, + json=json, + **opts, + ) + + def list( + self, req: process_dot_process__pb2.ListRequest, **opts + ) -> process_dot_process__pb2.ListResponse: + return self._list.call_unary(req, **opts) + + def alist( + self, req: process_dot_process__pb2.ListRequest, **opts + ) -> Coroutine[Any, Any, process_dot_process__pb2.ListResponse]: + return self._list.acall_unary(req, **opts) + + def connect( + self, req: process_dot_process__pb2.ConnectRequest, **opts + ) -> Generator[process_dot_process__pb2.ConnectResponse, Any, None]: + return self._connect.call_server_stream(req, **opts) + + def aconnect( + self, req: process_dot_process__pb2.ConnectRequest, **opts + ) -> AsyncGenerator[process_dot_process__pb2.ConnectResponse, Any]: + return self._connect.acall_server_stream(req, **opts) + + def start( + self, req: process_dot_process__pb2.StartRequest, **opts + ) -> Generator[process_dot_process__pb2.StartResponse, Any, None]: + return self._start.call_server_stream(req, **opts) + + def astart( + self, req: process_dot_process__pb2.StartRequest, **opts + ) -> AsyncGenerator[process_dot_process__pb2.StartResponse, Any]: + return self._start.acall_server_stream(req, **opts) + + def update( + self, req: process_dot_process__pb2.UpdateRequest, **opts + ) -> process_dot_process__pb2.UpdateResponse: + return self._update.call_unary(req, **opts) + + def aupdate( + self, req: process_dot_process__pb2.UpdateRequest, **opts + ) -> Coroutine[Any, Any, process_dot_process__pb2.UpdateResponse]: + return self._update.acall_unary(req, **opts) + + def stream_input( + self, req: process_dot_process__pb2.StreamInputRequest, **opts + ) -> process_dot_process__pb2.StreamInputResponse: + return self._stream_input.call_client_stream(req, **opts) + + def astream_input( + self, req: process_dot_process__pb2.StreamInputRequest, **opts + ) -> Coroutine[Any, Any, process_dot_process__pb2.StreamInputResponse]: + return self._stream_input.acall_client_stream(req, **opts) + + def send_input( + self, req: process_dot_process__pb2.SendInputRequest, **opts + ) -> process_dot_process__pb2.SendInputResponse: + return self._send_input.call_unary(req, **opts) + + def asend_input( + self, req: process_dot_process__pb2.SendInputRequest, **opts + ) -> Coroutine[Any, Any, process_dot_process__pb2.SendInputResponse]: + return self._send_input.acall_unary(req, **opts) + + def send_signal( + self, req: process_dot_process__pb2.SendSignalRequest, **opts + ) -> process_dot_process__pb2.SendSignalResponse: + return self._send_signal.call_unary(req, **opts) + + def asend_signal( + self, req: process_dot_process__pb2.SendSignalRequest, **opts + ) -> Coroutine[Any, Any, process_dot_process__pb2.SendSignalResponse]: + return self._send_signal.acall_unary(req, **opts) + + def close_stdin( + self, req: process_dot_process__pb2.CloseStdinRequest, **opts + ) -> process_dot_process__pb2.CloseStdinResponse: + return self._close_stdin.call_unary(req, **opts) + + def aclose_stdin( + self, req: process_dot_process__pb2.CloseStdinRequest, **opts + ) -> Coroutine[Any, Any, process_dot_process__pb2.CloseStdinResponse]: + return self._close_stdin.acall_unary(req, **opts) diff --git a/packages/python-sdk/e2b/envd/process/process_pb2.py b/packages/python-sdk/e2b/envd/process/process_pb2.py new file mode 100644 index 0000000..bb69b64 --- /dev/null +++ b/packages/python-sdk/e2b/envd/process/process_pb2.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: process/process.proto +# Protobuf Python Version: 5.26.1 +"""Generated protocol buffer code.""" + +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n\x15process/process.proto\x12\x07process"\\\n\x03PTY\x12%\n\x04size\x18\x01 \x01(\x0b\x32\x11.process.PTY.SizeR\x04size\x1a.\n\x04Size\x12\x12\n\x04\x63ols\x18\x01 \x01(\rR\x04\x63ols\x12\x12\n\x04rows\x18\x02 \x01(\rR\x04rows"\xc3\x01\n\rProcessConfig\x12\x10\n\x03\x63md\x18\x01 \x01(\tR\x03\x63md\x12\x12\n\x04\x61rgs\x18\x02 \x03(\tR\x04\x61rgs\x12\x34\n\x04\x65nvs\x18\x03 \x03(\x0b\x32 .process.ProcessConfig.EnvsEntryR\x04\x65nvs\x12\x15\n\x03\x63wd\x18\x04 \x01(\tH\x00R\x03\x63wd\x88\x01\x01\x1a\x37\n\tEnvsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x06\n\x04_cwd"\r\n\x0bListRequest"n\n\x0bProcessInfo\x12.\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x16.process.ProcessConfigR\x06\x63onfig\x12\x10\n\x03pid\x18\x02 \x01(\rR\x03pid\x12\x15\n\x03tag\x18\x03 \x01(\tH\x00R\x03tag\x88\x01\x01\x42\x06\n\x04_tag"B\n\x0cListResponse\x12\x32\n\tprocesses\x18\x01 \x03(\x0b\x32\x14.process.ProcessInfoR\tprocesses"\xb1\x01\n\x0cStartRequest\x12\x30\n\x07process\x18\x01 \x01(\x0b\x32\x16.process.ProcessConfigR\x07process\x12#\n\x03pty\x18\x02 \x01(\x0b\x32\x0c.process.PTYH\x00R\x03pty\x88\x01\x01\x12\x15\n\x03tag\x18\x03 \x01(\tH\x01R\x03tag\x88\x01\x01\x12\x19\n\x05stdin\x18\x04 \x01(\x08H\x02R\x05stdin\x88\x01\x01\x42\x06\n\x04_ptyB\x06\n\x04_tagB\x08\n\x06_stdin"p\n\rUpdateRequest\x12\x32\n\x07process\x18\x01 \x01(\x0b\x32\x18.process.ProcessSelectorR\x07process\x12#\n\x03pty\x18\x02 \x01(\x0b\x32\x0c.process.PTYH\x00R\x03pty\x88\x01\x01\x42\x06\n\x04_pty"\x10\n\x0eUpdateResponse"\x87\x04\n\x0cProcessEvent\x12\x38\n\x05start\x18\x01 \x01(\x0b\x32 .process.ProcessEvent.StartEventH\x00R\x05start\x12\x35\n\x04\x64\x61ta\x18\x02 \x01(\x0b\x32\x1f.process.ProcessEvent.DataEventH\x00R\x04\x64\x61ta\x12\x32\n\x03\x65nd\x18\x03 \x01(\x0b\x32\x1e.process.ProcessEvent.EndEventH\x00R\x03\x65nd\x12?\n\tkeepalive\x18\x04 \x01(\x0b\x32\x1f.process.ProcessEvent.KeepAliveH\x00R\tkeepalive\x1a\x1e\n\nStartEvent\x12\x10\n\x03pid\x18\x01 \x01(\rR\x03pid\x1a]\n\tDataEvent\x12\x18\n\x06stdout\x18\x01 \x01(\x0cH\x00R\x06stdout\x12\x18\n\x06stderr\x18\x02 \x01(\x0cH\x00R\x06stderr\x12\x12\n\x03pty\x18\x03 \x01(\x0cH\x00R\x03ptyB\x08\n\x06output\x1a|\n\x08\x45ndEvent\x12\x1b\n\texit_code\x18\x01 \x01(\x11R\x08\x65xitCode\x12\x16\n\x06\x65xited\x18\x02 \x01(\x08R\x06\x65xited\x12\x16\n\x06status\x18\x03 \x01(\tR\x06status\x12\x19\n\x05\x65rror\x18\x04 \x01(\tH\x00R\x05\x65rror\x88\x01\x01\x42\x08\n\x06_error\x1a\x0b\n\tKeepAliveB\x07\n\x05\x65vent"<\n\rStartResponse\x12+\n\x05\x65vent\x18\x01 \x01(\x0b\x32\x15.process.ProcessEventR\x05\x65vent">\n\x0f\x43onnectResponse\x12+\n\x05\x65vent\x18\x01 \x01(\x0b\x32\x15.process.ProcessEventR\x05\x65vent"s\n\x10SendInputRequest\x12\x32\n\x07process\x18\x01 \x01(\x0b\x32\x18.process.ProcessSelectorR\x07process\x12+\n\x05input\x18\x02 \x01(\x0b\x32\x15.process.ProcessInputR\x05input"\x13\n\x11SendInputResponse"C\n\x0cProcessInput\x12\x16\n\x05stdin\x18\x01 \x01(\x0cH\x00R\x05stdin\x12\x12\n\x03pty\x18\x02 \x01(\x0cH\x00R\x03ptyB\x07\n\x05input"\xea\x02\n\x12StreamInputRequest\x12>\n\x05start\x18\x01 \x01(\x0b\x32&.process.StreamInputRequest.StartEventH\x00R\x05start\x12;\n\x04\x64\x61ta\x18\x02 \x01(\x0b\x32%.process.StreamInputRequest.DataEventH\x00R\x04\x64\x61ta\x12\x45\n\tkeepalive\x18\x03 \x01(\x0b\x32%.process.StreamInputRequest.KeepAliveH\x00R\tkeepalive\x1a@\n\nStartEvent\x12\x32\n\x07process\x18\x01 \x01(\x0b\x32\x18.process.ProcessSelectorR\x07process\x1a\x38\n\tDataEvent\x12+\n\x05input\x18\x02 \x01(\x0b\x32\x15.process.ProcessInputR\x05input\x1a\x0b\n\tKeepAliveB\x07\n\x05\x65vent"\x15\n\x13StreamInputResponse"p\n\x11SendSignalRequest\x12\x32\n\x07process\x18\x01 \x01(\x0b\x32\x18.process.ProcessSelectorR\x07process\x12\'\n\x06signal\x18\x02 \x01(\x0e\x32\x0f.process.SignalR\x06signal"\x14\n\x12SendSignalResponse"G\n\x11\x43loseStdinRequest\x12\x32\n\x07process\x18\x01 \x01(\x0b\x32\x18.process.ProcessSelectorR\x07process"\x14\n\x12\x43loseStdinResponse"D\n\x0e\x43onnectRequest\x12\x32\n\x07process\x18\x01 \x01(\x0b\x32\x18.process.ProcessSelectorR\x07process"E\n\x0fProcessSelector\x12\x12\n\x03pid\x18\x01 \x01(\rH\x00R\x03pid\x12\x12\n\x03tag\x18\x02 \x01(\tH\x00R\x03tagB\n\n\x08selector*H\n\x06Signal\x12\x16\n\x12SIGNAL_UNSPECIFIED\x10\x00\x12\x12\n\x0eSIGNAL_SIGTERM\x10\x0f\x12\x12\n\x0eSIGNAL_SIGKILL\x10\t2\x91\x04\n\x07Process\x12\x33\n\x04List\x12\x14.process.ListRequest\x1a\x15.process.ListResponse\x12>\n\x07\x43onnect\x12\x17.process.ConnectRequest\x1a\x18.process.ConnectResponse0\x01\x12\x38\n\x05Start\x12\x15.process.StartRequest\x1a\x16.process.StartResponse0\x01\x12\x39\n\x06Update\x12\x16.process.UpdateRequest\x1a\x17.process.UpdateResponse\x12J\n\x0bStreamInput\x12\x1b.process.StreamInputRequest\x1a\x1c.process.StreamInputResponse(\x01\x12\x42\n\tSendInput\x12\x19.process.SendInputRequest\x1a\x1a.process.SendInputResponse\x12\x45\n\nSendSignal\x12\x1a.process.SendSignalRequest\x1a\x1b.process.SendSignalResponse\x12\x45\n\nCloseStdin\x12\x1a.process.CloseStdinRequest\x1a\x1b.process.CloseStdinResponseBW\n\x0b\x63om.processB\x0cProcessProtoP\x01\xa2\x02\x03PXX\xaa\x02\x07Process\xca\x02\x07Process\xe2\x02\x13Process\\GPBMetadata\xea\x02\x07Processb\x06proto3' +) + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "process.process_pb2", _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals["DESCRIPTOR"]._loaded_options = None + _globals[ + "DESCRIPTOR" + ]._serialized_options = b"\n\013com.processB\014ProcessProtoP\001\242\002\003PXX\252\002\007Process\312\002\007Process\342\002\023Process\\GPBMetadata\352\002\007Process" + _globals["_PROCESSCONFIG_ENVSENTRY"]._loaded_options = None + _globals["_PROCESSCONFIG_ENVSENTRY"]._serialized_options = b"8\001" + _globals["_SIGNAL"]._serialized_start = 2448 + _globals["_SIGNAL"]._serialized_end = 2520 + _globals["_PTY"]._serialized_start = 34 + _globals["_PTY"]._serialized_end = 126 + _globals["_PTY_SIZE"]._serialized_start = 80 + _globals["_PTY_SIZE"]._serialized_end = 126 + _globals["_PROCESSCONFIG"]._serialized_start = 129 + _globals["_PROCESSCONFIG"]._serialized_end = 324 + _globals["_PROCESSCONFIG_ENVSENTRY"]._serialized_start = 261 + _globals["_PROCESSCONFIG_ENVSENTRY"]._serialized_end = 316 + _globals["_LISTREQUEST"]._serialized_start = 326 + _globals["_LISTREQUEST"]._serialized_end = 339 + _globals["_PROCESSINFO"]._serialized_start = 341 + _globals["_PROCESSINFO"]._serialized_end = 451 + _globals["_LISTRESPONSE"]._serialized_start = 453 + _globals["_LISTRESPONSE"]._serialized_end = 519 + _globals["_STARTREQUEST"]._serialized_start = 522 + _globals["_STARTREQUEST"]._serialized_end = 699 + _globals["_UPDATEREQUEST"]._serialized_start = 701 + _globals["_UPDATEREQUEST"]._serialized_end = 813 + _globals["_UPDATERESPONSE"]._serialized_start = 815 + _globals["_UPDATERESPONSE"]._serialized_end = 831 + _globals["_PROCESSEVENT"]._serialized_start = 834 + _globals["_PROCESSEVENT"]._serialized_end = 1353 + _globals["_PROCESSEVENT_STARTEVENT"]._serialized_start = 1080 + _globals["_PROCESSEVENT_STARTEVENT"]._serialized_end = 1110 + _globals["_PROCESSEVENT_DATAEVENT"]._serialized_start = 1112 + _globals["_PROCESSEVENT_DATAEVENT"]._serialized_end = 1205 + _globals["_PROCESSEVENT_ENDEVENT"]._serialized_start = 1207 + _globals["_PROCESSEVENT_ENDEVENT"]._serialized_end = 1331 + _globals["_PROCESSEVENT_KEEPALIVE"]._serialized_start = 1333 + _globals["_PROCESSEVENT_KEEPALIVE"]._serialized_end = 1344 + _globals["_STARTRESPONSE"]._serialized_start = 1355 + _globals["_STARTRESPONSE"]._serialized_end = 1415 + _globals["_CONNECTRESPONSE"]._serialized_start = 1417 + _globals["_CONNECTRESPONSE"]._serialized_end = 1479 + _globals["_SENDINPUTREQUEST"]._serialized_start = 1481 + _globals["_SENDINPUTREQUEST"]._serialized_end = 1596 + _globals["_SENDINPUTRESPONSE"]._serialized_start = 1598 + _globals["_SENDINPUTRESPONSE"]._serialized_end = 1617 + _globals["_PROCESSINPUT"]._serialized_start = 1619 + _globals["_PROCESSINPUT"]._serialized_end = 1686 + _globals["_STREAMINPUTREQUEST"]._serialized_start = 1689 + _globals["_STREAMINPUTREQUEST"]._serialized_end = 2051 + _globals["_STREAMINPUTREQUEST_STARTEVENT"]._serialized_start = 1907 + _globals["_STREAMINPUTREQUEST_STARTEVENT"]._serialized_end = 1971 + _globals["_STREAMINPUTREQUEST_DATAEVENT"]._serialized_start = 1973 + _globals["_STREAMINPUTREQUEST_DATAEVENT"]._serialized_end = 2029 + _globals["_STREAMINPUTREQUEST_KEEPALIVE"]._serialized_start = 1333 + _globals["_STREAMINPUTREQUEST_KEEPALIVE"]._serialized_end = 1344 + _globals["_STREAMINPUTRESPONSE"]._serialized_start = 2053 + _globals["_STREAMINPUTRESPONSE"]._serialized_end = 2074 + _globals["_SENDSIGNALREQUEST"]._serialized_start = 2076 + _globals["_SENDSIGNALREQUEST"]._serialized_end = 2188 + _globals["_SENDSIGNALRESPONSE"]._serialized_start = 2190 + _globals["_SENDSIGNALRESPONSE"]._serialized_end = 2210 + _globals["_CLOSESTDINREQUEST"]._serialized_start = 2212 + _globals["_CLOSESTDINREQUEST"]._serialized_end = 2283 + _globals["_CLOSESTDINRESPONSE"]._serialized_start = 2285 + _globals["_CLOSESTDINRESPONSE"]._serialized_end = 2305 + _globals["_CONNECTREQUEST"]._serialized_start = 2307 + _globals["_CONNECTREQUEST"]._serialized_end = 2375 + _globals["_PROCESSSELECTOR"]._serialized_start = 2377 + _globals["_PROCESSSELECTOR"]._serialized_end = 2446 + _globals["_PROCESS"]._serialized_start = 2523 + _globals["_PROCESS"]._serialized_end = 3052 +# @@protoc_insertion_point(module_scope) diff --git a/packages/python-sdk/e2b/envd/process/process_pb2.pyi b/packages/python-sdk/e2b/envd/process/process_pb2.pyi new file mode 100644 index 0000000..e61158e --- /dev/null +++ b/packages/python-sdk/e2b/envd/process/process_pb2.pyi @@ -0,0 +1,316 @@ +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ( + ClassVar as _ClassVar, + Iterable as _Iterable, + Mapping as _Mapping, + Optional as _Optional, + Union as _Union, +) + +DESCRIPTOR: _descriptor.FileDescriptor + +class Signal(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + SIGNAL_UNSPECIFIED: _ClassVar[Signal] + SIGNAL_SIGTERM: _ClassVar[Signal] + SIGNAL_SIGKILL: _ClassVar[Signal] + +SIGNAL_UNSPECIFIED: Signal +SIGNAL_SIGTERM: Signal +SIGNAL_SIGKILL: Signal + +class PTY(_message.Message): + __slots__ = ("size",) + class Size(_message.Message): + __slots__ = ("cols", "rows") + COLS_FIELD_NUMBER: _ClassVar[int] + ROWS_FIELD_NUMBER: _ClassVar[int] + cols: int + rows: int + def __init__( + self, cols: _Optional[int] = ..., rows: _Optional[int] = ... + ) -> None: ... + + SIZE_FIELD_NUMBER: _ClassVar[int] + size: PTY.Size + def __init__(self, size: _Optional[_Union[PTY.Size, _Mapping]] = ...) -> None: ... + +class ProcessConfig(_message.Message): + __slots__ = ("cmd", "args", "envs", "cwd") + class EnvsEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__( + self, key: _Optional[str] = ..., value: _Optional[str] = ... + ) -> None: ... + + CMD_FIELD_NUMBER: _ClassVar[int] + ARGS_FIELD_NUMBER: _ClassVar[int] + ENVS_FIELD_NUMBER: _ClassVar[int] + CWD_FIELD_NUMBER: _ClassVar[int] + cmd: str + args: _containers.RepeatedScalarFieldContainer[str] + envs: _containers.ScalarMap[str, str] + cwd: str + def __init__( + self, + cmd: _Optional[str] = ..., + args: _Optional[_Iterable[str]] = ..., + envs: _Optional[_Mapping[str, str]] = ..., + cwd: _Optional[str] = ..., + ) -> None: ... + +class ListRequest(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class ProcessInfo(_message.Message): + __slots__ = ("config", "pid", "tag") + CONFIG_FIELD_NUMBER: _ClassVar[int] + PID_FIELD_NUMBER: _ClassVar[int] + TAG_FIELD_NUMBER: _ClassVar[int] + config: ProcessConfig + pid: int + tag: str + def __init__( + self, + config: _Optional[_Union[ProcessConfig, _Mapping]] = ..., + pid: _Optional[int] = ..., + tag: _Optional[str] = ..., + ) -> None: ... + +class ListResponse(_message.Message): + __slots__ = ("processes",) + PROCESSES_FIELD_NUMBER: _ClassVar[int] + processes: _containers.RepeatedCompositeFieldContainer[ProcessInfo] + def __init__( + self, processes: _Optional[_Iterable[_Union[ProcessInfo, _Mapping]]] = ... + ) -> None: ... + +class StartRequest(_message.Message): + __slots__ = ("process", "pty", "tag", "stdin") + PROCESS_FIELD_NUMBER: _ClassVar[int] + PTY_FIELD_NUMBER: _ClassVar[int] + TAG_FIELD_NUMBER: _ClassVar[int] + STDIN_FIELD_NUMBER: _ClassVar[int] + process: ProcessConfig + pty: PTY + tag: str + stdin: bool + def __init__( + self, + process: _Optional[_Union[ProcessConfig, _Mapping]] = ..., + pty: _Optional[_Union[PTY, _Mapping]] = ..., + tag: _Optional[str] = ..., + stdin: bool = ..., + ) -> None: ... + +class UpdateRequest(_message.Message): + __slots__ = ("process", "pty") + PROCESS_FIELD_NUMBER: _ClassVar[int] + PTY_FIELD_NUMBER: _ClassVar[int] + process: ProcessSelector + pty: PTY + def __init__( + self, + process: _Optional[_Union[ProcessSelector, _Mapping]] = ..., + pty: _Optional[_Union[PTY, _Mapping]] = ..., + ) -> None: ... + +class UpdateResponse(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class ProcessEvent(_message.Message): + __slots__ = ("start", "data", "end", "keepalive") + class StartEvent(_message.Message): + __slots__ = ("pid",) + PID_FIELD_NUMBER: _ClassVar[int] + pid: int + def __init__(self, pid: _Optional[int] = ...) -> None: ... + + class DataEvent(_message.Message): + __slots__ = ("stdout", "stderr", "pty") + STDOUT_FIELD_NUMBER: _ClassVar[int] + STDERR_FIELD_NUMBER: _ClassVar[int] + PTY_FIELD_NUMBER: _ClassVar[int] + stdout: bytes + stderr: bytes + pty: bytes + def __init__( + self, + stdout: _Optional[bytes] = ..., + stderr: _Optional[bytes] = ..., + pty: _Optional[bytes] = ..., + ) -> None: ... + + class EndEvent(_message.Message): + __slots__ = ("exit_code", "exited", "status", "error") + EXIT_CODE_FIELD_NUMBER: _ClassVar[int] + EXITED_FIELD_NUMBER: _ClassVar[int] + STATUS_FIELD_NUMBER: _ClassVar[int] + ERROR_FIELD_NUMBER: _ClassVar[int] + exit_code: int + exited: bool + status: str + error: str + def __init__( + self, + exit_code: _Optional[int] = ..., + exited: bool = ..., + status: _Optional[str] = ..., + error: _Optional[str] = ..., + ) -> None: ... + + class KeepAlive(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + + START_FIELD_NUMBER: _ClassVar[int] + DATA_FIELD_NUMBER: _ClassVar[int] + END_FIELD_NUMBER: _ClassVar[int] + KEEPALIVE_FIELD_NUMBER: _ClassVar[int] + start: ProcessEvent.StartEvent + data: ProcessEvent.DataEvent + end: ProcessEvent.EndEvent + keepalive: ProcessEvent.KeepAlive + def __init__( + self, + start: _Optional[_Union[ProcessEvent.StartEvent, _Mapping]] = ..., + data: _Optional[_Union[ProcessEvent.DataEvent, _Mapping]] = ..., + end: _Optional[_Union[ProcessEvent.EndEvent, _Mapping]] = ..., + keepalive: _Optional[_Union[ProcessEvent.KeepAlive, _Mapping]] = ..., + ) -> None: ... + +class StartResponse(_message.Message): + __slots__ = ("event",) + EVENT_FIELD_NUMBER: _ClassVar[int] + event: ProcessEvent + def __init__( + self, event: _Optional[_Union[ProcessEvent, _Mapping]] = ... + ) -> None: ... + +class ConnectResponse(_message.Message): + __slots__ = ("event",) + EVENT_FIELD_NUMBER: _ClassVar[int] + event: ProcessEvent + def __init__( + self, event: _Optional[_Union[ProcessEvent, _Mapping]] = ... + ) -> None: ... + +class SendInputRequest(_message.Message): + __slots__ = ("process", "input") + PROCESS_FIELD_NUMBER: _ClassVar[int] + INPUT_FIELD_NUMBER: _ClassVar[int] + process: ProcessSelector + input: ProcessInput + def __init__( + self, + process: _Optional[_Union[ProcessSelector, _Mapping]] = ..., + input: _Optional[_Union[ProcessInput, _Mapping]] = ..., + ) -> None: ... + +class SendInputResponse(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class ProcessInput(_message.Message): + __slots__ = ("stdin", "pty") + STDIN_FIELD_NUMBER: _ClassVar[int] + PTY_FIELD_NUMBER: _ClassVar[int] + stdin: bytes + pty: bytes + def __init__( + self, stdin: _Optional[bytes] = ..., pty: _Optional[bytes] = ... + ) -> None: ... + +class StreamInputRequest(_message.Message): + __slots__ = ("start", "data", "keepalive") + class StartEvent(_message.Message): + __slots__ = ("process",) + PROCESS_FIELD_NUMBER: _ClassVar[int] + process: ProcessSelector + def __init__( + self, process: _Optional[_Union[ProcessSelector, _Mapping]] = ... + ) -> None: ... + + class DataEvent(_message.Message): + __slots__ = ("input",) + INPUT_FIELD_NUMBER: _ClassVar[int] + input: ProcessInput + def __init__( + self, input: _Optional[_Union[ProcessInput, _Mapping]] = ... + ) -> None: ... + + class KeepAlive(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + + START_FIELD_NUMBER: _ClassVar[int] + DATA_FIELD_NUMBER: _ClassVar[int] + KEEPALIVE_FIELD_NUMBER: _ClassVar[int] + start: StreamInputRequest.StartEvent + data: StreamInputRequest.DataEvent + keepalive: StreamInputRequest.KeepAlive + def __init__( + self, + start: _Optional[_Union[StreamInputRequest.StartEvent, _Mapping]] = ..., + data: _Optional[_Union[StreamInputRequest.DataEvent, _Mapping]] = ..., + keepalive: _Optional[_Union[StreamInputRequest.KeepAlive, _Mapping]] = ..., + ) -> None: ... + +class StreamInputResponse(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class SendSignalRequest(_message.Message): + __slots__ = ("process", "signal") + PROCESS_FIELD_NUMBER: _ClassVar[int] + SIGNAL_FIELD_NUMBER: _ClassVar[int] + process: ProcessSelector + signal: Signal + def __init__( + self, + process: _Optional[_Union[ProcessSelector, _Mapping]] = ..., + signal: _Optional[_Union[Signal, str]] = ..., + ) -> None: ... + +class SendSignalResponse(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class CloseStdinRequest(_message.Message): + __slots__ = ("process",) + PROCESS_FIELD_NUMBER: _ClassVar[int] + process: ProcessSelector + def __init__( + self, process: _Optional[_Union[ProcessSelector, _Mapping]] = ... + ) -> None: ... + +class CloseStdinResponse(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class ConnectRequest(_message.Message): + __slots__ = ("process",) + PROCESS_FIELD_NUMBER: _ClassVar[int] + process: ProcessSelector + def __init__( + self, process: _Optional[_Union[ProcessSelector, _Mapping]] = ... + ) -> None: ... + +class ProcessSelector(_message.Message): + __slots__ = ("pid", "tag") + PID_FIELD_NUMBER: _ClassVar[int] + TAG_FIELD_NUMBER: _ClassVar[int] + pid: int + tag: str + def __init__( + self, pid: _Optional[int] = ..., tag: _Optional[str] = ... + ) -> None: ... diff --git a/packages/python-sdk/e2b/envd/rpc.py b/packages/python-sdk/e2b/envd/rpc.py new file mode 100644 index 0000000..0f9c277 --- /dev/null +++ b/packages/python-sdk/e2b/envd/rpc.py @@ -0,0 +1,139 @@ +import base64 + +import httpcore +from typing import Awaitable, Callable, Optional +from packaging.version import Version +from e2b_connect.client import Code, ConnectException + +from e2b.exceptions import ( + SandboxException, + InvalidArgumentException, + NotFoundException, + TimeoutException, + format_sandbox_timeout_exception, + AuthenticationException, + RateLimitException, +) +from e2b.connection_config import Username, default_username +from e2b.envd.versions import ENVD_DEFAULT_USER + +_DEFAULT_RPC_ERROR_MAP: dict[Code, Callable[[str], Exception]] = { + Code.invalid_argument: InvalidArgumentException, + Code.unauthenticated: AuthenticationException, + Code.not_found: NotFoundException, + Code.unavailable: format_sandbox_timeout_exception, + Code.resource_exhausted: lambda message: RateLimitException( + f"{message}: Rate limit exceeded, please try again later." + ), + Code.canceled: lambda message: TimeoutException( + f"{message}: This error is likely due to exceeding 'request_timeout'. You can pass the request timeout value as an option when making the request." + ), + Code.deadline_exceeded: lambda message: TimeoutException( + f"{message}: This error is likely due to exceeding 'timeout' — the total time a long running request (like process or directory watch) can be active. It can be modified by passing 'timeout' when making the request. Use '0' to disable the timeout." + ), +} + + +def format_terminated_exception( + e: Exception, + sandbox_running: Optional[bool], +) -> Exception: + """Handle an exception for a connection to the sandbox dropped mid-request: when a + sandbox health probe confirmed the sandbox is gone (``sandbox_running is False``), + return a ``TimeoutException``; otherwise return the original error unchanged.""" + if sandbox_running is False: + return TimeoutException( + f"{e}: The sandbox was killed or reached its end of life while the request was in flight." + ) + return e + + +def handle_rpc_exception( + e: Exception, + error_map: Optional[dict[Code, Callable[[str], Exception]]] = None, + sandbox_running: Optional[bool] = None, +): + """Handle errors from envd RPC calls by mapping gRPC status codes to specific exception types. + + :param e: The caught exception, expected to be a ``ConnectException`` or a transport-level ``httpcore`` error. + :param error_map: Optional map of gRPC codes to exception factories that override the defaults. + :param sandbox_running: Result of a sandbox health probe (``None`` when unknown), used to disambiguate a connection dropped mid-request. + :return: The corresponding exception. A connection dropped mid-request with the sandbox confirmed gone becomes a ``TimeoutException``; non-``ConnectException`` errors are otherwise returned as-is. + """ + if isinstance(e, ConnectException): + if error_map and e.status in error_map: + return error_map[e.status](e.message) + + if e.status in _DEFAULT_RPC_ERROR_MAP: + return _DEFAULT_RPC_ERROR_MAP[e.status](e.message) + + return SandboxException(f"{e.status}: {e.message}") + + # A remote protocol error (e.g. an HTTP/2 stream reset) means the connection to the + # sandbox was dropped mid-request — either the sandbox died or the network failed + if isinstance(e, httpcore.RemoteProtocolError): + return format_terminated_exception(e, sandbox_running) + + # A transport-level timeout from httpcore means a configured timeout was exceeded + # before the server responded: `request_timeout` on a unary call's read phase, or + # `connect`/`pool`/`write` on a stream's setup/send phase. Streams have no read + # timeout — the command `timeout` is enforced server-side and surfaces as a + # `deadline_exceeded` ConnectException instead. Unlike the JS SDK, where the + # request timeout is an `AbortSignal` that connect normalizes into a `Code.canceled` + # ConnectError, httpcore raises this raw transport error outside the ConnectException + # path, so we map it here to a `TimeoutException` for a consistent timeout error. + if isinstance(e, httpcore.TimeoutException): + return TimeoutException( + f"{e}: This error is likely due to exceeding 'timeout' — the total time a long running request (like process or directory watch) can be active — or 'request_timeout'. You can modify these by passing 'timeout' or 'request_timeout' when making the request. Use '0' to disable the timeout." + ) + + return e + + +def handle_rpc_exception_with_health( + e: Exception, + check_health: Optional[Callable[[], Optional[bool]]] = None, + error_map: Optional[dict[Code, Callable[[str], Exception]]] = None, +): + """Like :func:`handle_rpc_exception`, but when the connection to the sandbox was + dropped mid-request it probes the sandbox health to tell apart the sandbox being + killed from a transient network failure (e.g. a load balancer dropping the connection). + """ + sandbox_running = None + if check_health is not None and isinstance(e, httpcore.RemoteProtocolError): + try: + sandbox_running = check_health() + except Exception: + sandbox_running = None + return handle_rpc_exception(e, error_map, sandbox_running) + + +async def ahandle_rpc_exception_with_health( + e: Exception, + check_health: Optional[Callable[[], Awaitable[Optional[bool]]]] = None, + error_map: Optional[dict[Code, Callable[[str], Exception]]] = None, +): + """Async version of :func:`handle_rpc_exception_with_health`.""" + sandbox_running = None + if check_health is not None and isinstance(e, httpcore.RemoteProtocolError): + try: + sandbox_running = await check_health() + except Exception: + sandbox_running = None + return handle_rpc_exception(e, error_map, sandbox_running) + + +def authentication_header( + envd_version: Version, user: Optional[Username] = None +) -> dict[str, str]: + if user is None and envd_version < ENVD_DEFAULT_USER: + user = default_username + + if not user: + return {} + + value = f"{user}:" + + encoded = base64.b64encode(value.encode("utf-8")).decode("utf-8") + + return {"Authorization": f"Basic {encoded}"} diff --git a/packages/python-sdk/e2b/envd/versions.py b/packages/python-sdk/e2b/envd/versions.py new file mode 100644 index 0000000..2b4542c --- /dev/null +++ b/packages/python-sdk/e2b/envd/versions.py @@ -0,0 +1,11 @@ +from packaging.version import Version + +ENVD_VERSION_RECURSIVE_WATCH = Version("0.1.4") +ENVD_DEBUG_FALLBACK = Version("99.99.99") +ENVD_COMMANDS_STDIN = Version("0.3.0") +ENVD_DEFAULT_USER = Version("0.4.0") +ENVD_ENVD_CLOSE = Version("0.5.2") +ENVD_OCTET_STREAM_UPLOAD = Version("0.5.7") +ENVD_FILE_METADATA = Version("0.6.2") +ENVD_VERSION_FS_EVENT_ENTRY_INFO = Version("0.6.3") +ENVD_VERSION_WATCH_NETWORK_MOUNTS = Version("0.6.4") diff --git a/packages/python-sdk/e2b/exceptions.py b/packages/python-sdk/e2b/exceptions.py new file mode 100644 index 0000000..c7ed371 --- /dev/null +++ b/packages/python-sdk/e2b/exceptions.py @@ -0,0 +1,133 @@ +def format_sandbox_timeout_exception(message: str): + return TimeoutException( + f"{message}: This error is likely due to sandbox timeout. You can modify the sandbox timeout by passing 'timeout' when starting the sandbox or calling '.set_timeout' on the sandbox with the desired timeout." + ) + + +def format_request_timeout_error() -> Exception: + return TimeoutException( + "Request timed out — the 'request_timeout' option can be used to increase this timeout", + ) + + +class SandboxException(Exception): + """ + Base class for all sandbox errors. + + Raised when a general sandbox exception occurs. + """ + + pass + + +class TimeoutException(SandboxException): + """ + Raised when a timeout occurs. + + The `unavailable` exception type is caused by sandbox timeout.\n + The `canceled` exception type is caused by exceeding request timeout.\n + The `deadline_exceeded` exception type is caused by exceeding the timeout for process, watch, etc.\n + The `unknown` exception type is sometimes caused by the sandbox timeout when the request is not processed correctly.\n + """ + + pass + + +class InvalidArgumentException(SandboxException): + """ + Raised when an invalid argument is provided. + """ + + pass + + +class NotEnoughSpaceException(SandboxException): + """ + Raised when there is not enough disk space. + """ + + pass + + +class NotFoundException(SandboxException): + """ + Raised when a resource is not found. + + .. deprecated:: + Use :class:`FileNotFoundException` or :class:`SandboxNotFoundException` instead. + This class will be removed in the next major version. + """ + + pass + + +class FileNotFoundException(NotFoundException): + """ + Raised when a file or directory is not found inside a sandbox. + """ + + pass + + +class SandboxNotFoundException(NotFoundException): + """ + Raised when a sandbox is not found (e.g. it doesn't exist or is no longer running). + """ + + pass + + +class AuthenticationException(Exception): + """ + Raised when authentication fails. + """ + + pass + + +class GitAuthException(AuthenticationException): + """ + Raised when git authentication fails. + """ + + pass + + +class GitUpstreamException(SandboxException): + """ + Raised when git upstream tracking is missing. + """ + + pass + + +class TemplateException(SandboxException): + """ + Exception raised when the template uses old envd version. It isn't compatible with the new SDK. + """ + + +class RateLimitException(SandboxException): + """ + Raised when the API rate limit is exceeded. + """ + + +class BuildException(Exception): + """ + Raised when the build fails. + """ + + +class FileUploadException(BuildException): + """ + Raised when the file upload fails. + """ + + +class VolumeException(Exception): + """ + Base class for all volume errors. + + Raised when general volume errors occur. + """ diff --git a/packages/python-sdk/e2b/io_utils.py b/packages/python-sdk/e2b/io_utils.py new file mode 100644 index 0000000..8e02449 --- /dev/null +++ b/packages/python-sdk/e2b/io_utils.py @@ -0,0 +1,57 @@ +import asyncio +import zlib +from typing import IO, AsyncIterable, AsyncIterator, Iterable, Iterator + +IO_CHUNK_SIZE = 65_536 + + +def iter_io_chunks(data: IO) -> Iterator[bytes]: + """Read a file-like object in chunks, encoding text chunks to UTF-8.""" + while True: + chunk = data.read(IO_CHUNK_SIZE) + if not chunk: + break + yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") + + +async def aiter_io_chunks(data: IO) -> AsyncIterator[bytes]: + """Read a file-like object in chunks, encoding text chunks to UTF-8. + + `data.read` is a synchronous (potentially disk-blocking) call, so it runs in + a worker thread to avoid stalling the event loop during large uploads. + """ + while True: + chunk = await asyncio.to_thread(data.read, IO_CHUNK_SIZE) + if not chunk: + break + yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") + + +def _gzip_compressor(): + # wbits > 16 makes zlib produce a gzip-formatted stream. + return zlib.compressobj(wbits=zlib.MAX_WBITS | 16) + + +def gzip_iter(chunks: Iterable[bytes]) -> Iterator[bytes]: + """Gzip-compress a byte stream chunk by chunk.""" + compressor = _gzip_compressor() + for chunk in chunks: + compressed = compressor.compress(chunk) + if compressed: + yield compressed + yield compressor.flush() + + +async def agzip_iter(chunks: AsyncIterable[bytes]) -> AsyncIterator[bytes]: + """Gzip-compress a byte stream chunk by chunk. + + Compression is CPU-bound, so it runs in a worker thread to avoid stalling + the event loop during large uploads (zlib releases the GIL while + compressing, so the offload genuinely overlaps with the loop). + """ + compressor = _gzip_compressor() + async for chunk in chunks: + compressed = await asyncio.to_thread(compressor.compress, chunk) + if compressed: + yield compressed + yield await asyncio.to_thread(compressor.flush) diff --git a/packages/python-sdk/e2b/paginator.py b/packages/python-sdk/e2b/paginator.py new file mode 100644 index 0000000..a1a2e11 --- /dev/null +++ b/packages/python-sdk/e2b/paginator.py @@ -0,0 +1,52 @@ +from typing import Generic, Mapping, Optional, TypeVar + +from typing_extensions import Unpack + +from e2b.connection_config import ApiParams + +T = TypeVar("T") +OptsT = TypeVar("OptsT", bound=ApiParams) + + +class PaginatorBase(Generic[T, OptsT]): + """ + Shared pagination state for cursor-based list endpoints. + + Owns the `has_next` / `next_token` state and the reading of the + `x-next-token` response header (via `_update_pagination`). Each concrete + paginator implements `next_items` to do the actual fetching for its + endpoint, so any model can expose pagination by subclassing this without + reimplementing the bookkeeping. + + `T` is the item type returned by `next_items`; `OptsT` is the connection + options type accepted by the paginator (an `ApiParams`-compatible TypedDict). + """ + + def __init__( + self, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[OptsT], + ): + self._opts: OptsT = opts + self.limit = limit + self._has_next = True + self._next_token = next_token + + @property + def has_next(self) -> bool: + """ + Returns True if there are more items to fetch. + """ + return self._has_next + + @property + def next_token(self) -> Optional[str]: + """ + Returns the next token to use for pagination. + """ + return self._next_token + + def _update_pagination(self, headers: Mapping[str, str]) -> None: + self._next_token = headers.get("x-next-token") + self._has_next = bool(self._next_token) diff --git a/packages/python-sdk/e2b/py.typed b/packages/python-sdk/e2b/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/python-sdk/e2b/sandbox/_git/__init__.py b/packages/python-sdk/e2b/sandbox/_git/__init__.py new file mode 100644 index 0000000..e62ad9f --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/_git/__init__.py @@ -0,0 +1,85 @@ +from e2b.sandbox._git.args import ( + build_add_args, + build_branches_args, + build_checkout_branch_args, + build_clone_plan, + build_commit_args, + build_credential_approve_command, + build_create_branch_args, + build_delete_branch_args, + build_git_command, + build_has_upstream_args, + build_pull_args, + build_push_args, + build_remote_add_args, + build_remote_add_shell_command, + build_remote_get_command, + build_remote_get_url_args, + build_remote_set_url_args, + build_reset_args, + build_restore_args, + build_status_args, + shell_escape, +) +from e2b.sandbox._git.auth import ( + build_auth_error_message, + build_upstream_error_message, + is_auth_failure, + is_missing_upstream, + strip_credentials, + with_credentials, +) +from e2b.sandbox._git.config import resolve_config_scope +from e2b.sandbox._git.parse import ( + derive_repo_dir_from_url, + parse_git_branches, + parse_git_status, + parse_remote_url, +) +from e2b.sandbox._git.types import ( + ClonePlan, + GitBranches, + GitFileStatus, + GitResetMode, + GitStatus, +) + +__all__ = [ + "build_add_args", + "build_auth_error_message", + "build_branches_args", + "build_checkout_branch_args", + "build_clone_plan", + "build_commit_args", + "build_credential_approve_command", + "build_create_branch_args", + "build_delete_branch_args", + "build_git_command", + "build_has_upstream_args", + "build_pull_args", + "build_push_args", + "build_remote_add_args", + "build_remote_add_shell_command", + "build_remote_get_command", + "build_remote_get_url_args", + "build_remote_set_url_args", + "build_reset_args", + "build_restore_args", + "build_status_args", + "build_upstream_error_message", + "derive_repo_dir_from_url", + "is_auth_failure", + "is_missing_upstream", + "parse_git_branches", + "parse_git_status", + "parse_remote_url", + "resolve_config_scope", + "shell_escape", + "strip_credentials", + "with_credentials", + "ClonePlan", + "GitBranches", + "GitFileStatus", + "GitResetMode", + "GitStatus", +] diff --git a/packages/python-sdk/e2b/sandbox/_git/args.py b/packages/python-sdk/e2b/sandbox/_git/args.py new file mode 100644 index 0000000..9d214b2 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/_git/args.py @@ -0,0 +1,363 @@ +from typing import List, Optional + +from e2b.exceptions import InvalidArgumentException +from e2b.sandbox._git.auth import strip_credentials, with_credentials +from e2b.sandbox._git.parse import derive_repo_dir_from_url +from e2b.sandbox._git.types import ClonePlan + + +def shell_escape(value: str) -> str: + """ + Escape a string for safe use in a shell command. + + :param value: Value to escape + :return: Shell-escaped string + """ + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def build_git_command(args: List[str], repo_path: Optional[str] = None) -> str: + """ + Build a shell-safe git command string. + + :param args: Git command arguments + :param repo_path: Repository path for `git -C`, if provided + :return: Shell-safe git command + """ + parts = ["git"] + if repo_path: + parts.extend(["-C", repo_path]) + parts.extend(args) + return " ".join(shell_escape(part) for part in parts) + + +def build_push_args( + remote_name: Optional[str], + *, + remote: Optional[str], + branch: Optional[str], + set_upstream: bool, +) -> List[str]: + """ + Build arguments for a git push command. + + :param remote_name: Resolved remote name, if any + :param remote: Remote name override + :param branch: Branch name to push + :param set_upstream: Whether to set upstream tracking + :return: List of git push arguments + """ + args = ["push"] + target_remote = remote_name or remote + if set_upstream and target_remote: + args.append("--set-upstream") + if target_remote: + args.append(target_remote) + if branch: + args.append(branch) + return args + + +def build_pull_args( + remote: Optional[str], + branch: Optional[str], + remote_name: Optional[str] = None, +) -> List[str]: + """ + Build arguments for a git pull command. + + :param remote: Remote name override + :param branch: Branch name to pull + :param remote_name: Resolved remote name, if any + :return: List of git pull arguments + """ + args = ["pull"] + target_remote = remote_name or remote + if target_remote: + args.append(target_remote) + if branch: + args.append(branch) + return args + + +def build_remote_add_args(name: str, url: str, fetch: bool) -> List[str]: + """ + Build arguments for a git remote add command. + + :param name: Remote name + :param url: Remote URL + :param fetch: Whether to fetch after adding the remote + :return: List of git remote add arguments + """ + if not name or not url: + raise InvalidArgumentException( + "Both remote name and URL are required to add a git remote." + ) + + args = ["remote", "add"] + if fetch: + args.append("-f") + args.extend([name, url]) + return args + + +def build_remote_add_shell_command( + args: List[str], + path: str, + name: str, + url: str, + fetch: bool, +) -> str: + """ + Build a shell command that adds or updates a remote and optionally fetches. + + :param args: Base git remote add args + :param path: Repository path + :param name: Remote name + :param url: Remote URL + :param fetch: Whether to fetch after adding the remote + :return: Shell command string + """ + add_cmd = build_git_command(args, path) + set_url_cmd = build_git_command(build_remote_set_url_args(name, url), path) + cmd = f"{add_cmd} || {set_url_cmd}" + if fetch: + fetch_cmd = build_git_command(["fetch", name], path) + cmd = f"({cmd}) && {fetch_cmd}" + return cmd + + +def build_remote_get_url_args(name: str) -> List[str]: + """ + Build arguments for a git remote get-url command. + """ + return ["remote", "get-url", name] + + +def build_remote_set_url_args(name: str, url: str) -> List[str]: + """ + Build arguments for a git remote set-url command. + """ + return ["remote", "set-url", name, url] + + +def build_remote_get_command(path: str, name: str) -> str: + """ + Build a shell command that returns the remote URL or empty output. + + :param path: Repository path + :param name: Remote name + :return: Shell command string + """ + if not name: + raise InvalidArgumentException("Remote name is required.") + + return f"{build_git_command(build_remote_get_url_args(name), path)} || true" + + +def build_credential_approve_command( + username: str, + password: str, + host: str, + protocol: str, +) -> str: + """ + Build a git credential approve command for the given credentials. + """ + target_host = host.strip() or "github.com" + target_protocol = protocol.strip() or "https" + credential_input = "\n".join( + [ + f"protocol={target_protocol}", + f"host={target_host}", + f"username={username}", + f"password={password}", + "", + "", + ] + ) + return ( + f"printf %s {shell_escape(credential_input)} | " + f"{build_git_command(['credential', 'approve'])}" + ) + + +def build_has_upstream_args() -> List[str]: + """ + Build arguments for a git upstream check command. + """ + return ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"] + + +def build_status_args() -> List[str]: + """ + Build arguments for a git status command. + """ + return ["status", "--porcelain=1", "-b"] + + +def build_branches_args() -> List[str]: + """ + Build arguments for a git branch listing command. + """ + return ["branch", "--format=%(refname:short)\t%(HEAD)"] + + +def build_create_branch_args(branch: str) -> List[str]: + """ + Build arguments for a git checkout -b command. + """ + return ["checkout", "-b", branch] + + +def build_checkout_branch_args(branch: str) -> List[str]: + """ + Build arguments for a git checkout command. + """ + return ["checkout", branch] + + +def build_delete_branch_args(branch: str, force: bool) -> List[str]: + """ + Build arguments for a git branch delete command. + """ + return ["branch", "-D" if force else "-d", branch] + + +def build_add_args(files: Optional[List[str]], all: bool) -> List[str]: + """ + Build arguments for a git add command. + """ + args = ["add"] + if not files: + args.append("-A" if all else ".") + else: + args.append("--") + args.extend(files) + return args + + +def build_commit_args( + message: str, + author_name: Optional[str], + author_email: Optional[str], + allow_empty: bool, +) -> List[str]: + """ + Build arguments for a git commit command. + """ + args = ["commit", "-m", message] + if allow_empty: + args.append("--allow-empty") + author_args: List[str] = [] + if author_name: + author_args.extend(["-c", f"user.name={author_name}"]) + if author_email: + author_args.extend(["-c", f"user.email={author_email}"]) + if author_args: + args = author_args + args + return args + + +def build_reset_args( + mode: Optional[str], + target: Optional[str], + paths: Optional[List[str]], +) -> List[str]: + """ + Build arguments for a git reset command. + """ + allowed_modes = ["soft", "mixed", "hard", "merge", "keep"] + if mode and mode not in allowed_modes: + raise InvalidArgumentException( + f"Reset mode must be one of {', '.join(allowed_modes)}." + ) + + args = ["reset"] + if mode: + args.append(f"--{mode}") + if target: + args.append(target) + if paths: + args.append("--") + args.extend(paths) + return args + + +def build_restore_args( + paths: List[str], + staged: Optional[bool], + worktree: Optional[bool], + source: Optional[str], +) -> List[str]: + """ + Build arguments for a git restore command. + """ + if not paths: + raise InvalidArgumentException("At least one path is required.") + + resolved_staged = staged + resolved_worktree = worktree + if staged is None and worktree is None: + resolved_worktree = True + elif staged is True and worktree is None: + resolved_worktree = False + elif staged is None and worktree is not None: + resolved_staged = False + + if resolved_staged is False and resolved_worktree is False: + raise InvalidArgumentException( + "At least one of staged or worktree must be true." + ) + + args = ["restore"] + if resolved_worktree: + args.append("--worktree") + if resolved_staged: + args.append("--staged") + if source: + args.extend(["--source", source]) + args.append("--") + args.extend(paths) + return args + + +def build_clone_plan( + url: str, + path: Optional[str], + branch: Optional[str], + depth: Optional[int], + auth_username: Optional[str], + auth_password: Optional[str], + dangerously_store_credentials: bool, +) -> ClonePlan: + """ + Build clone arguments and metadata for post-clone credential stripping. + """ + clone_url = ( + with_credentials(url, auth_username, auth_password) + if auth_username and auth_password + else url + ) + sanitized_url = strip_credentials(clone_url) + should_strip = not dangerously_store_credentials and sanitized_url != clone_url + repo_path = path if not should_strip else path or derive_repo_dir_from_url(url) + if should_strip and not repo_path: + raise InvalidArgumentException( + "A destination path is required when using credentials without storing them." + ) + + args = ["clone", clone_url] + if branch: + args.extend(["--branch", branch, "--single-branch"]) + if depth: + args.extend(["--depth", str(depth)]) + if path: + args.append(path) + + return ClonePlan( + args=args, + repo_path=repo_path, + sanitized_url=sanitized_url if should_strip else None, + should_strip=should_strip, + ) diff --git a/packages/python-sdk/e2b/sandbox/_git/auth.py b/packages/python-sdk/e2b/sandbox/_git/auth.py new file mode 100644 index 0000000..a93511f --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/_git/auth.py @@ -0,0 +1,132 @@ +from typing import Optional +from urllib.parse import urlparse, urlunparse + +from e2b.exceptions import InvalidArgumentException +from e2b.sandbox.commands.command_handle import CommandExitException + + +def with_credentials(url: str, username: Optional[str], password: Optional[str]) -> str: + """ + Add HTTP(S) credentials to a Git URL. + + :param url: Git repository URL + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :return: URL with embedded credentials + """ + if not username and not password: + return url + if not username or not password: + raise InvalidArgumentException( + "Both username and password are required when using Git credentials." + ) + + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise InvalidArgumentException( + "Only http(s) Git URLs support username/password credentials." + ) + + netloc = f"{username}:{password}@{parsed.netloc}" + return urlunparse(parsed._replace(netloc=netloc)) + + +def strip_credentials(url: str) -> str: + """ + Strip HTTP(S) credentials from a Git URL. + + :param url: Git repository URL + :return: URL without embedded credentials + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return url + if not parsed.username and not parsed.password: + return url + + host = parsed.hostname or "" + if parsed.port: + host = f"{host}:{parsed.port}" + + return urlunparse(parsed._replace(netloc=host)) + + +def is_auth_failure(err: Exception) -> bool: + """ + Check whether a git command failed due to authentication issues. + + :param err: Exception raised by a git command + :return: True when the error matches common authentication failures + """ + if not isinstance(err, CommandExitException): + return False + + message = f"{err.stderr}\n{err.stdout}".lower() + auth_snippets = [ + "authentication failed", + "terminal prompts disabled", + "could not read username", + "invalid username or password", + "access denied", + "permission denied", + "not authorized", + ] + return any(snippet in message for snippet in auth_snippets) + + +def is_missing_upstream(err: Exception) -> bool: + """ + Check whether a git command failed due to missing upstream tracking. + + :param err: Exception raised by a git command + :return: True when the error matches common upstream failures + """ + if not isinstance(err, CommandExitException): + return False + + message = f"{err.stderr}\n{err.stdout}".lower() + upstream_snippets = [ + "has no upstream branch", + "no upstream branch", + "no upstream configured", + "no tracking information for the current branch", + "no tracking information", + "set the remote as upstream", + "set the upstream branch", + "please specify which branch you want to merge with", + ] + return any(snippet in message for snippet in upstream_snippets) + + +def build_auth_error_message(action: str, missing_password: bool) -> str: + """ + Build a git authentication error message for the given action. + + :param action: Git action name + :param missing_password: Whether the password/token is missing + :return: Error message string + """ + if missing_password: + return f"Git {action} requires a password/token for private repositories." + return f"Git {action} requires credentials for private repositories." + + +def build_upstream_error_message(action: str) -> str: + """ + Build a git upstream tracking error message for the given action. + + :param action: Git action name + :return: Error message string + """ + if action == "push": + return ( + "Git push failed because no upstream branch is configured. " + "Set upstream once with set_upstream=True (and optional remote/branch), " + "or pass remote and branch explicitly." + ) + + return ( + "Git pull failed because no upstream branch is configured. " + "Pass remote and branch explicitly, or set upstream once (push with " + "set_upstream=True or run: git branch --set-upstream-to=origin/ )." + ) diff --git a/packages/python-sdk/e2b/sandbox/_git/config.py b/packages/python-sdk/e2b/sandbox/_git/config.py new file mode 100644 index 0000000..0c851b0 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/_git/config.py @@ -0,0 +1,32 @@ +from typing import Optional + +from e2b.exceptions import InvalidArgumentException + + +def resolve_config_scope( + scope: Optional[str], path: Optional[str] +) -> tuple[str, Optional[str]]: + """ + Resolve a git config scope flag and repository path. + + :param scope: Requested scope ("global", "local", "system") + :param path: Repository path for local scope + :return: Tuple of (scope flag, repository path) + """ + scope_name = (scope or "global").strip().lower() + if scope_name not in {"global", "local", "system"}: + raise InvalidArgumentException( + "Git config scope must be one of: global, local, system." + ) + + if scope_name == "local": + if not path: + raise InvalidArgumentException( + "Repository path is required when scope is local." + ) + return "--local", path + + if scope_name == "system": + return "--system", None + + return "--global", None diff --git a/packages/python-sdk/e2b/sandbox/_git/parse.py b/packages/python-sdk/e2b/sandbox/_git/parse.py new file mode 100644 index 0000000..e6cea69 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/_git/parse.py @@ -0,0 +1,222 @@ +from typing import List, Optional +from urllib.parse import urlparse + +from e2b.exceptions import InvalidArgumentException +from e2b.sandbox._git.types import GitBranches, GitFileStatus, GitStatus + + +def derive_repo_dir_from_url(url: str) -> Optional[str]: + """ + Derive the default repository directory name from a Git URL. + + :param url: Git repository URL + :return: Repository directory name, if it can be determined + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return None + trimmed_path = parsed.path.rstrip("/") + if not trimmed_path: + return None + last_segment = trimmed_path.split("/")[-1] + if not last_segment: + return None + return last_segment[:-4] if last_segment.endswith(".git") else last_segment + + +def _parse_ahead_behind(segment: Optional[str]) -> tuple[int, int]: + """ + Parse the ahead/behind segment from porcelain branch info. + + :param segment: Segment text like "ahead 2, behind 1" + :return: Tuple of (ahead, behind) + """ + if not segment: + return 0, 0 + ahead = 0 + behind = 0 + if "ahead" in segment: + try: + ahead = int(segment.split("ahead")[1].split(",")[0].strip()) + except Exception: + ahead = 0 + if "behind" in segment: + try: + behind = int(segment.split("behind")[1].split(",")[0].strip()) + except Exception: + behind = 0 + return ahead, behind + + +def _normalize_branch_name(name: str) -> str: + """ + Normalize branch names from porcelain branch output. + + :param name: Raw branch name section + :return: Normalized branch name + """ + if name.startswith("HEAD (detached at "): + return name.replace("HEAD (detached at ", "").rstrip(")") + return ( + name.replace("HEAD (no branch)", "HEAD") + .replace("No commits yet on ", "") + .replace("Initial commit on ", "") + ) + + +def _derive_status(index_status: str, working_status: str) -> str: + """ + Derive a normalized status label from porcelain status characters. + + :param index_status: Index status character + :param working_status: Working tree status character + :return: Normalized status label + """ + statuses = {index_status, working_status} + if "U" in statuses: + return "conflict" + if "R" in statuses: + return "renamed" + if "C" in statuses: + return "copied" + if "D" in statuses: + return "deleted" + if "A" in statuses: + return "added" + if "M" in statuses: + return "modified" + if "T" in statuses: + return "typechange" + if "?" in statuses: + return "untracked" + return "unknown" + + +def parse_git_status(output: str) -> GitStatus: + """ + Parse `git status --porcelain=1 -b` output into a structured object. + + :param output: Git status output + :return: Parsed `GitStatus` + """ + lines = [line.rstrip() for line in output.split("\n") if line.strip()] + current_branch: Optional[str] = None + upstream: Optional[str] = None + ahead = 0 + behind = 0 + detached = False + file_status: List[GitFileStatus] = [] + + if not lines: + return GitStatus( + current_branch=current_branch, + upstream=upstream, + ahead=ahead, + behind=behind, + detached=detached, + file_status=file_status, + ) + + branch_line = lines[0] + if branch_line.startswith("## "): + branch_info = branch_line[3:] + ahead_start = branch_info.find(" [") + branch_part = branch_info if ahead_start == -1 else branch_info[:ahead_start] + ahead_part = None if ahead_start == -1 else branch_info[ahead_start + 2 : -1] + normalized_branch = _normalize_branch_name(branch_part) + raw_branch = branch_part + is_detached = raw_branch.startswith("HEAD (detached at ") or ( + "detached" in raw_branch + ) + + if is_detached or normalized_branch.startswith("HEAD"): + detached = True + elif "..." in normalized_branch: + branch, upstream_branch = normalized_branch.split("...") + current_branch = branch or None + upstream = upstream_branch or None + else: + current_branch = normalized_branch or None + + ahead, behind = _parse_ahead_behind(ahead_part) + + for line in lines[1:]: + if line.startswith("?? "): + name = line[3:] + file_status.append( + GitFileStatus( + name=name, + status="untracked", + index_status="?", + working_tree_status="?", + staged=False, + ) + ) + continue + + if len(line) < 3: + continue + index_status = line[0] + working_status = line[1] + path = line[3:] + renamed_from: Optional[str] = None + name = path + if " -> " in path: + renamed_from, name = path.split(" -> ", 1) + + file_status.append( + GitFileStatus( + name=name, + status=_derive_status(index_status, working_status), + index_status=index_status, + working_tree_status=working_status, + staged=index_status not in (" ", "?"), + renamed_from=renamed_from, + ) + ) + + return GitStatus( + current_branch=current_branch, + upstream=upstream, + ahead=ahead, + behind=behind, + detached=detached, + file_status=file_status, + ) + + +def parse_git_branches(output: str) -> GitBranches: + """ + Parse `git branch --format=%(refname:short)\t%(HEAD)` output. + + :param output: Git branch output + :return: Parsed `GitBranches` + """ + branches: List[str] = [] + current_branch: Optional[str] = None + + lines = [line.strip() for line in output.split("\n") if line.strip()] + for line in lines: + parts = line.split("\t") + name = parts[0] + branches.append(name) + if len(parts) > 1 and parts[1] == "*": + current_branch = name + + return GitBranches(branches=branches, current_branch=current_branch) + + +def parse_remote_url(output: str, remote: str) -> str: + """ + Parse a git remote URL output and validate it's present. + + :param output: Git remote get-url output + :param remote: Remote name for the error message + :return: Remote URL + """ + url = output.strip() + if not url: + raise InvalidArgumentException( + f'Remote "{remote}" URL not found in repository.' + ) + return url diff --git a/packages/python-sdk/e2b/sandbox/_git/types.py b/packages/python-sdk/e2b/sandbox/_git/types.py new file mode 100644 index 0000000..35006e2 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/_git/types.py @@ -0,0 +1,149 @@ +from dataclasses import dataclass +from typing import List, Optional + +from typing_extensions import Literal + +GitResetMode = Literal["soft", "mixed", "hard", "merge", "keep"] +"""Mode for a git reset operation.""" + + +@dataclass +class GitFileStatus: + """ + Parsed git status entry for a file. + + :param name: Path relative to the repository root + :param status: Normalized status string (e.g. "modified", "added") + :param index_status: Index status character from porcelain output + :param working_tree_status: Working tree status character from porcelain output + :param staged: Whether the change is staged + :param renamed_from: Original path when the file was renamed + """ + + name: str + status: str + index_status: str + working_tree_status: str + staged: bool + renamed_from: Optional[str] = None + + +@dataclass +class GitStatus: + """ + Parsed git repository status. + + :param current_branch: Current branch name, if available + :param upstream: Upstream branch name, if available + :param ahead: Number of commits the branch is ahead of upstream + :param behind: Number of commits the branch is behind upstream + :param detached: Whether HEAD is detached + :param file_status: List of file status entries + """ + + current_branch: Optional[str] + upstream: Optional[str] + ahead: int + behind: int + detached: bool + file_status: List[GitFileStatus] + + @property + def is_clean(self) -> bool: + """ + Return True when there are no tracked or untracked file changes. + """ + return len(self.file_status) == 0 + + @property + def has_changes(self) -> bool: + """ + Return True when there are any tracked or untracked file changes. + """ + return len(self.file_status) > 0 + + @property + def has_staged(self) -> bool: + """ + Return True when at least one file has staged changes. + """ + return any(item.staged for item in self.file_status) + + @property + def has_untracked(self) -> bool: + """ + Return True when at least one file is untracked. + """ + return any(item.status == "untracked" for item in self.file_status) + + @property + def has_conflicts(self) -> bool: + """ + Return True when at least one file is in conflict. + """ + return any(item.status == "conflict" for item in self.file_status) + + @property + def total_count(self) -> int: + """ + Return the total number of changed files. + """ + return len(self.file_status) + + @property + def staged_count(self) -> int: + """ + Return the number of files with staged changes. + """ + return sum(1 for item in self.file_status if item.staged) + + @property + def unstaged_count(self) -> int: + """ + Return the number of files with unstaged changes. + """ + return sum(1 for item in self.file_status if not item.staged) + + @property + def untracked_count(self) -> int: + """ + Return the number of untracked files. + """ + return sum(1 for item in self.file_status if item.status == "untracked") + + @property + def conflict_count(self) -> int: + """ + Return the number of files with merge conflicts. + """ + return sum(1 for item in self.file_status if item.status == "conflict") + + +@dataclass +class GitBranches: + """ + Parsed git branch list. + + :param branches: List of branch names + :param current_branch: Current branch name, if available + """ + + branches: List[str] + current_branch: Optional[str] + + +@dataclass +class ClonePlan: + """ + Prepared arguments and metadata for git clone. + + :param args: Command arguments for git clone + :param repo_path: Repository path to use for post-clone adjustments + :param sanitized_url: Credential-stripped URL to restore + :param should_strip: Whether to reset the remote URL after clone + """ + + args: List[str] + repo_path: Optional[str] + sanitized_url: Optional[str] + should_strip: bool diff --git a/packages/python-sdk/e2b/sandbox/commands/command_handle.py b/packages/python-sdk/e2b/sandbox/commands/command_handle.py new file mode 100644 index 0000000..043f871 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/commands/command_handle.py @@ -0,0 +1,69 @@ +from dataclasses import dataclass +from typing import Optional + +from e2b.exceptions import SandboxException + +Stdout = str +""" +Command stdout output. +""" +Stderr = str +""" +Command stderr output. +""" +PtyOutput = bytes +""" +Pty output. +""" + + +@dataclass +class PtySize: + """ + Pseudo-terminal size. + """ + + rows: int + """ + Number of rows. + """ + cols: int + """ + Number of columns. + """ + + +@dataclass +class CommandResult: + """ + Command execution result. + """ + + stderr: str + """ + Command stderr output. + """ + stdout: str + """ + Command stdout output. + """ + exit_code: int + """ + Command exit code. + + `0` if the command finished successfully. + """ + error: Optional[str] + """ + Error message from command execution if it failed. + """ + + +@dataclass +class CommandExitException(SandboxException, CommandResult): + """ + Exception raised when a command exits with a non-zero exit code. + """ + + def __str__(self): + return f"Command exited with code {self.exit_code} and error:\n{self.stderr}" diff --git a/packages/python-sdk/e2b/sandbox/commands/main.py b/packages/python-sdk/e2b/sandbox/commands/main.py new file mode 100644 index 0000000..67d52ed --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/commands/main.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass +from typing import Dict, List, Optional + + +@dataclass +class ProcessInfo: + """ + Information about a command, PTY session or start command running in the sandbox as process. + """ + + pid: int + """ + Process ID. + """ + + tag: Optional[str] + """ + Custom tag used for identifying special commands like start command in the custom template. + """ + + cmd: str + """ + Command that was executed. + """ + + args: List[str] + """ + Command arguments. + """ + + envs: Dict[str, str] + """ + Environment variables used for the command. + """ + + cwd: Optional[str] + """ + Executed command working directory. + """ diff --git a/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py new file mode 100644 index 0000000..4d04556 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py @@ -0,0 +1,337 @@ +import gzip +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from io import IOBase, TextIOBase +from typing import IO, AsyncIterator, Dict, Iterator, Optional, Union, TypedDict + +import httpx + +from e2b.envd.filesystem import filesystem_pb2 +from e2b.exceptions import InvalidArgumentException +from e2b.io_utils import agzip_iter, aiter_io_chunks, gzip_iter, iter_io_chunks + + +class FileType(Enum): + """ + Enum representing the type of filesystem object. + """ + + FILE = "file" + """ + Filesystem object is a file. + """ + DIR = "dir" + """ + Filesystem object is a directory. + """ + + +def map_file_type(ft: filesystem_pb2.FileType): + if ft == filesystem_pb2.FileType.FILE_TYPE_FILE: + return FileType.FILE + elif ft == filesystem_pb2.FileType.FILE_TYPE_DIRECTORY: + return FileType.DIR + + +def map_file_type_str(value: Optional[str]) -> Optional[FileType]: + """Map a `/files` API type string to `FileType`, `None` when unknown.""" + if value == FileType.FILE.value: + return FileType.FILE + elif value == FileType.DIR.value: + return FileType.DIR + return None + + +@dataclass +class WriteInfo: + """ + Sandbox filesystem object information. + """ + + name: str + """ + Name of the filesystem object. + """ + type: Optional[FileType] + """ + Type of the filesystem object. + """ + path: str + """ + Path to the filesystem object. + """ + metadata: Optional[Dict[str, str]] = field(default=None, kw_only=True) + """ + User-defined metadata stored on the file as `user.e2b.*` extended + attributes. On writes this reflects the metadata supplied on upload; on + reads (`get_info`, `list`, `rename`) it reflects any `user.e2b.*` xattr on + the file, including ones set out-of-band. `None` when none is set. + """ + + @classmethod + def from_dict(cls, payload: Dict) -> "WriteInfo": + """Build a `WriteInfo` from a `/files` upload response entry.""" + return cls( + name=payload["name"], + type=map_file_type_str(payload.get("type")), + path=payload["path"], + metadata=map_metadata(payload.get("metadata")), + ) + + +@dataclass +class EntryInfo(WriteInfo): + """ + Extended sandbox filesystem object information. + """ + + size: int + """ + Size of the filesystem object in bytes. + """ + mode: int + """ + File mode and permission bits. + """ + permissions: str + """ + String representation of file permissions (e.g. 'rwxr-xr-x'). + """ + owner: str + """ + Owner of the filesystem object. + """ + group: str + """ + Group owner of the filesystem object. + """ + modified_time: datetime + """ + Last modification time of the filesystem object. + """ + symlink_target: Optional[str] = None + """ + Target of the symlink if the filesystem object is a symlink. + If the filesystem object is not a symlink, this field is None. + """ + + +def map_entry_info(entry: filesystem_pb2.EntryInfo) -> EntryInfo: + return EntryInfo( + name=entry.name, + type=map_file_type(entry.type), + path=entry.path, + size=entry.size, + mode=entry.mode, + permissions=entry.permissions, + owner=entry.owner, + group=entry.group, + modified_time=entry.modified_time.ToDatetime(tzinfo=timezone.utc), + # Optional, we can't directly access symlink_target otherwise it will be "" instead of None + symlink_target=( + entry.symlink_target if entry.HasField("symlink_target") else None + ), + metadata=map_metadata(entry.metadata), + ) + + +class WriteEntry(TypedDict): + """ + Contains path and data of the file to be written to the filesystem. + """ + + path: str + data: Union[str, bytes, IO] + + +class FileStreamReader(Iterator[bytes]): + """Iterator over a streamed file download. + + Returned by ``Sandbox.files.read(format="stream")``. It owns the underlying + HTTP response and releases its pooled connection as soon as the stream is + fully consumed, an error is raised while reading (including the idle-read + timeout, which raises ``httpx.ReadTimeout``), or the reader is closed. + + There is no garbage-collection safety net, so always consume it fully, use + it as a context manager, or call :meth:`close`:: + + with sandbox.files.read(path, format="stream") as stream: + for chunk in stream: + ... + """ + + def __init__(self, response: httpx.Response): + self._response = response + self._iterator = response.iter_bytes() + self._closed = False + + def __iter__(self) -> Iterator[bytes]: + return self + + def __next__(self) -> bytes: + try: + return next(self._iterator) + except BaseException: + # Covers normal end (StopIteration) and read errors alike. + self.close() + raise + + def close(self) -> None: + """Release the underlying HTTP connection. Safe to call multiple times.""" + if self._closed: + return + self._closed = True + self._response.close() + + def __enter__(self) -> "FileStreamReader": + return self + + def __exit__(self, *exc_info) -> None: + self.close() + + +class AsyncFileStreamReader(AsyncIterator[bytes]): + """Async iterator over a streamed file download. + + Returned by ``AsyncSandbox.files.read(format="stream")``. It owns the + underlying HTTP response and releases its pooled connection as soon as the + stream is fully consumed, an error is raised while reading (including the + idle-read timeout, which raises ``httpx.ReadTimeout``), or the reader is + closed. + + There is no garbage-collection safety net (releasing an async connection + requires awaiting ``aclose()``, which a finalizer cannot do reliably), so + always consume it fully, use it as an async context manager, or call + :meth:`aclose`:: + + async with await sandbox.files.read(path, format="stream") as stream: + async for chunk in stream: + ... + """ + + def __init__(self, response: httpx.Response): + self._response = response + self._iterator = response.aiter_bytes() + self._closed = False + + def __aiter__(self) -> AsyncIterator[bytes]: + return self + + async def __anext__(self) -> bytes: + try: + return await self._iterator.__anext__() + except BaseException: + # Covers normal end (StopAsyncIteration) and read errors alike. + await self.aclose() + raise + + async def aclose(self) -> None: + """Release the underlying HTTP connection. Safe to call multiple times.""" + if self._closed: + return + self._closed = True + await self._response.aclose() + + async def __aenter__(self) -> "AsyncFileStreamReader": + return self + + async def __aexit__(self, *exc_info) -> None: + await self.aclose() + + +def _to_httpx_file(file_path: str, file_data: Union[str, bytes, IO]): + """Build an httpx multipart `("file", (name, data))` tuple for the upload.""" + if isinstance(file_data, (str, bytes)): + return ("file", (file_path, file_data)) + elif isinstance(file_data, TextIOBase): + return ("file", (file_path, file_data.read())) + elif isinstance(file_data, IOBase): + return ("file", (file_path, file_data)) + else: + raise InvalidArgumentException(f"Unsupported data type for file {file_path}") + + +def to_upload_body( + data: Union[str, bytes, IO], + use_gzip: bool = False, +) -> Union[bytes, IO, Iterator[bytes]]: + """Prepare file data for upload, optionally gzip-compressed. + + File-like objects are streamed in chunks instead of being buffered in + memory. + """ + if isinstance(data, (str, bytes)): + raw = data.encode("utf-8") if isinstance(data, str) else data + return gzip.compress(raw) if use_gzip else raw + elif isinstance(data, (TextIOBase, IOBase)): + if use_gzip: + return gzip_iter(iter_io_chunks(data)) + if isinstance(data, TextIOBase): + # Text-mode IO yields str chunks—encode them while streaming. + return iter_io_chunks(data) + # httpx streams binary file-like objects in chunks without buffering. + return data + else: + raise InvalidArgumentException(f"Unsupported data type: {type(data)}") + + +def to_upload_body_async( + data: Union[str, bytes, IO], + use_gzip: bool = False, +) -> Union[bytes, AsyncIterator[bytes]]: + """Prepare file data for upload with async httpx, optionally gzip-compressed. + + File-like objects are streamed in chunks instead of being buffered in + memory. Async httpx requires an async iterable for streamed request bodies. + """ + if isinstance(data, (str, bytes)): + raw = data.encode("utf-8") if isinstance(data, str) else data + return gzip.compress(raw) if use_gzip else raw + elif isinstance(data, (TextIOBase, IOBase)): + chunks = aiter_io_chunks(data) + return agzip_iter(chunks) if use_gzip else chunks + else: + raise InvalidArgumentException(f"Unsupported data type: {type(data)}") + + +METADATA_HEADER_PREFIX = "X-Metadata-" + +# Metadata keys travel as `X-Metadata-` HTTP header names, so they must be +# valid header tokens (RFC 7230); values travel as header values, restricted to +# printable US-ASCII. +_METADATA_KEY_REGEX = re.compile(r"\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\Z") +_METADATA_VALUE_REGEX = re.compile(r"\A[\x20-\x7e]*\Z") + + +def validate_metadata(metadata: Optional[Dict[str, str]]) -> None: + """Validate metadata keys/values before they are sent as upload headers.""" + if not metadata: + return + for key, value in metadata.items(): + if not _METADATA_KEY_REGEX.match(key): + raise InvalidArgumentException( + f"Invalid metadata key {key!r}: keys must be non-empty and use only " + "HTTP token characters (letters, digits and !#$%&'*+-.^_`|~)." + ) + if not _METADATA_VALUE_REGEX.match(value): + raise InvalidArgumentException( + f"Invalid metadata value for key {key!r}: values must be printable US-ASCII." + ) + + +def metadata_to_headers( + metadata: Optional[Dict[str, str]], +) -> Dict[str, str]: + """Translate user metadata into the `X-Metadata-*` upload headers envd reads.""" + if not metadata: + return {} + return {f"{METADATA_HEADER_PREFIX}{key}": value for key, value in metadata.items()} + + +def map_metadata(metadata) -> Optional[Dict[str, str]]: + """Normalize a proto/HTTP metadata map: drop empties and return a plain dict or None.""" + if not metadata: + return None + return dict(metadata) diff --git a/packages/python-sdk/e2b/sandbox/filesystem/watch_handle.py b/packages/python-sdk/e2b/sandbox/filesystem/watch_handle.py new file mode 100644 index 0000000..6d6767d --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/filesystem/watch_handle.py @@ -0,0 +1,70 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +from e2b.envd.filesystem.filesystem_pb2 import EventType +from e2b.sandbox.filesystem.filesystem import EntryInfo + + +class FilesystemEventType(Enum): + """ + Enum representing the type of filesystem event. + """ + + CHMOD = "chmod" + """ + Filesystem object permissions were changed. + """ + CREATE = "create" + """ + Filesystem object was created. + """ + REMOVE = "remove" + """ + Filesystem object was removed. + """ + RENAME = "rename" + """ + Filesystem object was renamed. + """ + WRITE = "write" + """ + Filesystem object was written to. + """ + + +def map_event_type(event: EventType): + if event == EventType.EVENT_TYPE_CHMOD: + return FilesystemEventType.CHMOD + elif event == EventType.EVENT_TYPE_CREATE: + return FilesystemEventType.CREATE + elif event == EventType.EVENT_TYPE_REMOVE: + return FilesystemEventType.REMOVE + elif event == EventType.EVENT_TYPE_RENAME: + return FilesystemEventType.RENAME + elif event == EventType.EVENT_TYPE_WRITE: + return FilesystemEventType.WRITE + + +@dataclass +class FilesystemEvent: + """ + Contains information about the filesystem event - the name of the file and the type of the event. + """ + + name: str + """ + Relative path to the filesystem object. + """ + type: FilesystemEventType + """ + Filesystem operation event type. + """ + entry: Optional[EntryInfo] = None + """ + Information about the entry that triggered the event. + + Only populated when the watch was started with `include_entry=True` and the + sandbox's envd version supports it. It may be `None` for events where the entry + no longer exists at the path (e.g. remove or rename-away events). + """ diff --git a/packages/python-sdk/e2b/sandbox/main.py b/packages/python-sdk/e2b/sandbox/main.py new file mode 100644 index 0000000..b068771 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/main.py @@ -0,0 +1,227 @@ +import urllib.parse +from typing import Optional, TypedDict + +from packaging.version import Version + +from e2b.connection_config import ConnectionConfig, default_username +from e2b.envd.api import ENVD_API_FILES_ROUTE +from e2b.envd.versions import ENVD_DEFAULT_USER +from e2b.exceptions import InvalidArgumentException +from e2b.sandbox.signature import get_signature + + +class SandboxOpts(TypedDict): + sandbox_id: str + sandbox_domain: Optional[str] + envd_version: Version + envd_access_token: Optional[str] + traffic_access_token: Optional[str] + connection_config: ConnectionConfig + + +class SandboxBase: + mcp_port = 50005 + + default_sandbox_timeout = 300 + + default_template = "base" + default_mcp_template = "mcp-gateway" + + def __init__( + self, + sandbox_id: str, + envd_version: Version, + envd_access_token: Optional[str], + sandbox_domain: Optional[str], + connection_config: ConnectionConfig, + traffic_access_token: Optional[str] = None, + ): + self.__connection_config = connection_config + self.__sandbox_id = sandbox_id + self.__sandbox_domain = sandbox_domain or self.connection_config.domain + self.__envd_version = envd_version + self.__envd_access_token = envd_access_token + self.__traffic_access_token = traffic_access_token + self.__envd_api_url = self.connection_config.get_sandbox_url( + self.sandbox_id, self.sandbox_domain + ) + self.__envd_direct_url = self.connection_config.get_sandbox_direct_url( + self.sandbox_id, self.sandbox_domain + ) + self.__mcp_token: Optional[str] = None + + @property + def _envd_access_token(self) -> Optional[str]: + """Private property to access the envd token""" + return self.__envd_access_token + + @property + def _mcp_token(self) -> Optional[str]: + return self.__mcp_token + + @_mcp_token.setter + def _mcp_token(self, token: str) -> None: + self.__mcp_token = token + + @property + def connection_config(self) -> ConnectionConfig: + return self.__connection_config + + @property + def _envd_version(self) -> Version: + return self.__envd_version + + @property + def traffic_access_token(self) -> Optional[str]: + return self.__traffic_access_token + + @property + def sandbox_domain(self) -> str: + return self.__sandbox_domain + + @property + def envd_api_url(self) -> str: + return self.__envd_api_url + + @property + def envd_direct_url(self) -> str: + return self.__envd_direct_url + + @property + def sandbox_id(self) -> str: + """ + Unique identifier of the sandbox. + """ + return self.__sandbox_id + + def _file_url( + self, + path: str, + user: Optional[str] = None, + signature: Optional[str] = None, + signature_expiration: Optional[int] = None, + ) -> str: + url = urllib.parse.urljoin(self.envd_direct_url, ENVD_API_FILES_ROUTE) + query = {"path": path} if path else {} + + if user: + query["username"] = user + + if signature: + query["signature"] = signature + + if signature_expiration: + if signature is None: + raise ValueError("signature_expiration requires signature to be set") + query["signature_expiration"] = str(signature_expiration) + + params = urllib.parse.urlencode( + query, + quote_via=urllib.parse.quote, + ) + url = urllib.parse.urljoin(url, f"?{params}") + + return url + + def download_url( + self, + path: str, + user: Optional[str] = None, + use_signature_expiration: Optional[int] = None, + ) -> str: + """ + Get the URL to download a file from the sandbox. + + :param path: Path to the file to download + :param user: User to download the file as + :param use_signature_expiration: Expiration time for the signed URL in seconds + + :return: URL for downloading file + """ + + use_signature = self._envd_access_token is not None + if not use_signature and use_signature_expiration is not None: + raise InvalidArgumentException( + "Signature expiration can be used only when sandbox is created as secured." + ) + + username = user + if username is None and self._envd_version < ENVD_DEFAULT_USER: + username = default_username + + if use_signature: + signature = get_signature( + path, + "read", + username, + self._envd_access_token, + use_signature_expiration, + ) + return self._file_url( + path, username, signature["signature"], signature["expiration"] + ) + else: + return self._file_url(path, username) + + def upload_url( + self, + path: str, + user: Optional[str] = None, + use_signature_expiration: Optional[int] = None, + ) -> str: + """ + Get the URL to upload a file to the sandbox. + + You have to send a POST request to this URL with the file as multipart/form-data. + + :param path: Path to the file to upload + :param user: User to upload the file as + :param use_signature_expiration: Expiration time for the signed URL in seconds + + :return: URL for uploading file + """ + + use_signature = self._envd_access_token is not None + if not use_signature and use_signature_expiration is not None: + raise InvalidArgumentException( + "Signature expiration can be used only when sandbox is created as secured." + ) + + username = user + if username is None and self._envd_version < ENVD_DEFAULT_USER: + username = default_username + + if use_signature: + signature = get_signature( + path, + "write", + username, + self._envd_access_token, + use_signature_expiration, + ) + return self._file_url( + path, username, signature["signature"], signature["expiration"] + ) + else: + return self._file_url(path, username) + + def get_host(self, port: int) -> str: + """ + Get the host address to connect to the sandbox. + You can then use this address to connect to the sandbox port from outside the sandbox via HTTP or WebSocket. + + :param port: Port to connect to + + :return: Host address to connect to + """ + return self.connection_config.get_host( + self.sandbox_id, self.sandbox_domain, port + ) + + def get_mcp_url(self) -> str: + """ + Get the MCP URL for the sandbox. + + :returns MCP URL for the sandbox. + """ + return f"https://{self.get_host(self.mcp_port)}/mcp" diff --git a/packages/python-sdk/e2b/sandbox/mcp.py b/packages/python-sdk/e2b/sandbox/mcp.py new file mode 100644 index 0000000..e982bbd --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/mcp.py @@ -0,0 +1,1949 @@ +# generated by datamodel-codegen: +# filename: mcp-server.json + +from __future__ import annotations + +from typing import Any, Dict, List, TypedDict + +from typing_extensions import NotRequired + + +class Airtable(TypedDict): + airtableApiKey: str + nodeenv: str + + +class Aks(TypedDict): + accessLevel: str + """ + Access level for the MCP server, One of [ readonly, readwrite, admin ] + """ + additionalTools: NotRequired[str] + """ + Comma-separated list of additional tools, One of [ helm, cilium ] + """ + allowNamespaces: NotRequired[str] + """ + Comma-separated list of namespaces to allow access to. If not specified, all namespaces are allowed. + """ + azureDir: str + """ + Path to the Azure configuration directory (e.g. /home/azureuser/.azure). Used for Azure CLI authentication, you should be logged in (e.g. run `az login`) on the host before starting the MCP server. + """ + containerUser: NotRequired[str] + """ + Username or UID of the container user (format [:] e.g. 10000), ensuring correct permissions to access the Azure and kubeconfig files. Leave empty to use default user in the container. + """ + kubeconfig: str + """ + Path to the kubeconfig file for the AKS cluster (e.g. /home/azureuser/.kube/config). Used to connect to the AKS cluster. + """ + + +class ApiGateway(TypedDict): + api1HeaderAuthorization: str + api1Name: str + api1SwaggerUrl: str + + +class Apify(TypedDict): + apifyToken: str + tools: str + """ + Comma-separated list of tools to enable. Can be either a tool category, a specific tool, or an Apify Actor. For example: "actors,docs,apify/rag-web-browser". For more details visit https://mcp.apify.com. + """ + + +class Arxiv(TypedDict): + storagePath: str + """ + Directory path where downloaded papers will be stored + """ + + +class AstGrep(TypedDict): + path: str + + +class AstraDb(TypedDict): + astraDbApplicationToken: str + endpoint: str + + +class Atlan(TypedDict): + apiKey: str + baseUrl: str + + +class AtlasDocs(TypedDict): + apiUrl: str + + +class Atlassian(TypedDict): + confluenceApiToken: NotRequired[str] + confluencePersonalToken: NotRequired[str] + confluenceUrl: str + confluenceUsername: NotRequired[str] + jiraApiToken: NotRequired[str] + jiraPersonalToken: NotRequired[str] + jiraUrl: str + jiraUsername: NotRequired[str] + + +class AudienseInsights(TypedDict): + audienseClientSecret: NotRequired[str] + clientId: str + twitterBearerToken: NotRequired[str] + + +class AwsKbRetrievalServer(TypedDict): + accessKeyId: str + awsSecretAccessKey: NotRequired[str] + + +class BeagleSecurity(TypedDict): + beagleSecurityApiToken: str + + +class Bitrefill(TypedDict): + apiId: str + apiSecret: NotRequired[str] + + +class Box(TypedDict): + clientId: str + clientSecret: NotRequired[str] + + +class Brave(TypedDict): + apiKey: str + + +class Browserbase(TypedDict): + apiKey: str + geminiApiKey: str + projectId: str + + +class Buildkite(TypedDict): + apiToken: str + + +class Camunda(TypedDict): + camundahost: str + + +class CdataConnectcloud(TypedDict): + cdataPat: NotRequired[str] + username: str + + +class Charmhealth(TypedDict): + charmhealthApiKey: str + charmhealthBaseUrl: str + charmhealthClientId: str + charmhealthClientSecret: str + charmhealthRedirectUri: str + charmhealthRefreshToken: str + charmhealthTokenUrl: str + + +class Chroma(TypedDict): + apiKey: str + + +class Circleci(TypedDict): + token: str + url: str + + +class Clickhouse(TypedDict): + connectTimeout: str + host: str + password: str + port: str + secure: str + sendReceiveTimeout: str + user: str + verify: str + + +class Close(TypedDict): + apiKey: str + + +class CloudRun(TypedDict): + credentialsPath: str + """ + path to application-default credentials (eg $HOME/.config/gcloud/application_default_credentials.json ) + """ + + +class Cockroachdb(TypedDict): + caPath: str + crdbPwd: str + database: str + host: str + port: int + sslCertfile: str + sslKeyfile: str + sslMode: str + username: str + + +class Couchbase(TypedDict): + cbBucketName: str + """ + Bucket in the Couchbase cluster to use for the MCP server. + """ + cbConnectionString: str + """ + Connection string for the Couchbase cluster. + """ + cbMcpReadOnlyQueryMode: str + """ + Setting to "true" (default) enables read-only query mode while running SQL++ queries. + """ + cbPassword: str + cbUsername: str + """ + Username for the Couchbase cluster with access to the bucket. + """ + + +class Cylera(TypedDict): + cyleraBaseUrl: str + cyleraPassword: str + cyleraUsername: str + + +class CyreslabAiShodan(TypedDict): + shodanApiKey: str + + +class Dappier(TypedDict): + apiKey: str + + +class DappierRemote(TypedDict): + dappierRemoteApiKey: str + + +class Dart(TypedDict): + host: str + token: str + + +class DatabaseServer(TypedDict): + databaseUrl: str + """ + Connection string for your database. Examples: SQLite: sqlite+aiosqlite:///data/mydb.db, PostgreSQL: postgresql+asyncpg://user:password@localhost:5432/mydb, MySQL: mysql+aiomysql://user:password@localhost:3306/mydb + """ + + +class Descope(TypedDict): + managementKey: NotRequired[str] + projectId: str + + +class DesktopCommander(TypedDict): + paths: List[str] + """ + List of directories that Desktop Commander can access + """ + + +class DevhubCms(TypedDict): + devhubApiKey: NotRequired[str] + devhubApiSecret: NotRequired[str] + url: str + + +class Discord(TypedDict): + discordToken: str + + +class Dockerhub(TypedDict): + hubPatToken: str + username: str + + +class DodoPayments(TypedDict): + dodoPaymentsApiKey: str + + +class Dreamfactory(TypedDict): + dreamfactoryapikey: str + dreamfactoryurl: str + + +class Dynatrace(TypedDict): + oauthClientId: str + oauthClientSecret: str + url: str + + +class E2b(TypedDict): + apiKey: str + + +class Edubase(TypedDict): + apiKey: NotRequired[str] + app: str + url: str + + +class Elasticsearch(TypedDict): + esApiKey: NotRequired[str] + url: str + + +class Elevenlabs(TypedDict): + apiKey: NotRequired[str] + data: str + + +class Everart(TypedDict): + apiKey: str + + +class Exa(TypedDict): + apiKey: str + + +class Explorium(TypedDict): + apiAccessToken: str + + +class Fibery(TypedDict): + apiToken: str + host: str + + +class Filesystem(TypedDict): + paths: List[str] + + +class Firecrawl(TypedDict): + apiKey: str + creditCriticalThreshold: int + creditWarningThreshold: int + retryBackoffFactor: int + retryDelay: int + retryMax: int + retryMaxDelay: int + url: str + + +class Firewalla(TypedDict): + boxId: str + """ + Your Firewalla Box Global ID + """ + firewallaMspToken: str + mspId: str + """ + Your Firewalla MSP domain (e.g., yourdomain.firewalla.net) + """ + + +class Flexprice(TypedDict): + apiKey: str + baseUrl: str + + +class Git(TypedDict): + paths: List[str] + + +class Github(TypedDict): + personalAccessToken: str + + +class GithubChat(TypedDict): + githubApiKey: str + + +class GithubOfficial(TypedDict): + githubPersonalAccessToken: str + + +class Gitlab(TypedDict): + personalAccessToken: str + url: str + """ + api url - optional for self-hosted instances + """ + + +class Glif(TypedDict): + apiToken: str + ids: str + ignoredSaved: bool + + +class Gmail(TypedDict): + emailAddress: str + """ + Your Gmail email address + """ + emailPassword: NotRequired[str] + + +class GoogleMaps(TypedDict): + googleMapsApiKey: str + + +class GoogleMapsComprehensive(TypedDict): + googleMapsApiKey: str + + +class Grafana(TypedDict): + apiKey: str + url: str + + +class Gyazo(TypedDict): + accessToken: str + + +class Hackle(TypedDict): + apiKey: str + + +class HandwritingOcr(TypedDict): + apiToken: str + + +class Hdx(TypedDict): + appIdentifier: str + + +class Heroku(TypedDict): + apiKey: str + + +class Hostinger(TypedDict): + apitoken: str + + +class Hoverfly(TypedDict): + data: str + + +class Hubspot(TypedDict): + apiKey: str + + +class Hummingbot(TypedDict): + apiUrl: str + hummingbotApiPassword: NotRequired[str] + hummingbotApiUsername: NotRequired[str] + + +class HusqvarnaAutomower(TypedDict): + clientId: str + husqvarnaClientSecret: str + + +class Hyperbrowser(TypedDict): + apiKey: str + + +class Hyperspell(TypedDict): + collection: str + token: str + useResources: bool + + +class Iaptic(TypedDict): + apiKey: NotRequired[str] + appName: str + + +class InspektorGadget(TypedDict): + gadgetImages: NotRequired[str] + """ + Comma-separated list of gadget images (trace_dns, trace_tcp, etc) to use, allowing control over which gadgets are available as MCP tools + """ + kubeconfig: str + """ + Path to the kubeconfig file for accessing Kubernetes clusters + """ + + +class Jetbrains(TypedDict): + port: int + + +class KafkaSchemaReg(TypedDict): + registryUrl: str + """ + Schema Registry URL + """ + schemaRegistryPassword: NotRequired[str] + schemaRegistryUser: NotRequired[str] + slimMode: NotRequired[str] + """ + Enable SLIM_MODE for better performance + """ + viewonly: NotRequired[str] + """ + Enable read-only mode + """ + + +class Kagisearch(TypedDict): + engine: str + kagiApiKey: str + + +class Keboola(TypedDict): + kbcStorageToken: str + kbcWorkspaceSchema: str + + +class Kong(TypedDict): + konnectAccessToken: str + region: str + + +class Kubectl(TypedDict): + kubeconfig: str + + +class Kubernetes(TypedDict): + configPath: str + """ + the path to the host .kube/config + """ + + +class Lara(TypedDict): + accessKeySecret: NotRequired[str] + keyId: str + + +class Line(TypedDict): + channelAccessToken: NotRequired[str] + userId: str + + +class Linkedin(TypedDict): + linkedinCookie: str + userAgent: str + """ + Custom user agent string (optional, helps avoid detection and cookie login issues) + """ + + +class Maestro(TypedDict): + apiKeyApiKey: str + + +class Mapbox(TypedDict): + accessToken: str + + +class MapboxDevkit(TypedDict): + mapboxAccessToken: str + + +class Markdownify(TypedDict): + paths: List[str] + + +class Markitdown(TypedDict): + paths: List[str] + + +class MercadoLibre(TypedDict): + mercadoLibreApiKey: str + + +class MercadoPago(TypedDict): + mercadoPagoApiKey: str + + +class Metabase(TypedDict): + apiKey: str + metabaseurl: str + metabaseusername: str + password: str + + +class Mongodb(TypedDict): + mdbMcpConnectionString: str + + +class MultiversxMx(TypedDict): + network: str + wallet: str + + +class NasdaqDataLink(TypedDict): + nasdaqDataLinkApiKey: str + + +class Needle(TypedDict): + needleApiKey: str + + +class Neo4jCloudAuraApi(TypedDict): + clientId: str + neo4jAuraClientSecret: NotRequired[str] + serverAllowOrigins: NotRequired[str] + serverAllowedHosts: NotRequired[str] + serverHost: NotRequired[str] + serverPath: NotRequired[str] + serverPort: NotRequired[str] + transport: NotRequired[str] + + +class Neo4jCypher(TypedDict): + database: NotRequired[str] + namespace: NotRequired[str] + neo4jPassword: NotRequired[str] + readOnly: NotRequired[bool] + readTimeout: NotRequired[str] + responseTokenLimit: NotRequired[str] + serverAllowOrigins: NotRequired[str] + serverAllowedHosts: NotRequired[str] + serverHost: NotRequired[str] + serverPath: NotRequired[str] + serverPort: NotRequired[str] + transport: NotRequired[str] + url: str + username: str + + +class Neo4jDataModeling(TypedDict): + serverAllowOrigins: str + serverAllowedHosts: str + serverHost: str + serverPath: str + serverPort: str + transport: str + + +class Neo4jMemory(TypedDict): + database: NotRequired[str] + neo4jPassword: NotRequired[str] + serverAllowOrigins: NotRequired[str] + serverAllowedHosts: NotRequired[str] + serverHost: NotRequired[str] + serverPath: NotRequired[str] + serverPort: NotRequired[str] + transport: NotRequired[str] + url: str + username: str + + +class Neon(TypedDict): + apiKey: str + + +class Notion(TypedDict): + internalIntegrationToken: str + + +class Obsidian(TypedDict): + apiKey: str + + +class OktaMcpFctr(TypedDict): + clientOrgurl: str + """ + Okta organization URL (e.g., https://dev-123456.okta.com) + """ + concurrentLimit: NotRequired[str] + """ + Maximum concurrent requests to Okta API + """ + logLevel: NotRequired[str] + """ + Logging level for server output + """ + oktaApiToken: NotRequired[str] + + +class Omi(TypedDict): + apiKey: str + + +class OnlyofficeDocspace(TypedDict): + baseUrl: str + docspaceApiKey: str + docspaceAuthToken: str + docspacePassword: str + docspaceUsername: str + dynamic: bool + origin: str + toolsets: str + userAgent: str + + +class Openapi(TypedDict): + mode: str + + +class OpenapiSchema(TypedDict): + SchemaPath: str + + +class Openweather(TypedDict): + owmApiKey: str + + +class Opik(TypedDict): + apiBaseUrl: str + apiKey: str + workspaceName: str + + +class Opine(TypedDict): + opineApiKey: str + + +class Oracle(TypedDict): + oracleConnectionString: str + oracleUser: str + password: str + + +class Oxylabs(TypedDict): + password: NotRequired[str] + username: str + + +class PerplexityAsk(TypedDict): + perplexityApiKey: str + + +class Pia(TypedDict): + apiKey: str + + +class Pinecone(TypedDict): + apiKey: str + assistantHost: str + + +class Playwright(TypedDict): + data: str + + +class PluggedinMcpProxy(TypedDict): + pluggedinApiBaseUrl: str + """ + Base URL for the Plugged.in API (optional, defaults to https://plugged.in for cloud or http://localhost:12005 for self-hosted) + """ + pluggedinApiKey: str + + +class PolarSignals(TypedDict): + polarSignalsApiKey: str + + +class Pomodash(TypedDict): + apiKey: str + + +class Postgres(TypedDict): + url: str + + +class Postman(TypedDict): + apiKey: str + + +class Prometheus(TypedDict): + prometheusUrl: str + """ + The URL of your Prometheus server + """ + + +class Quantconnect(TypedDict): + agentname: str + quantconnectapitoken: str + quantconnectuserid: str + + +class Razorpay(TypedDict): + keyId: str + keySecret: NotRequired[str] + + +class Reddit(TypedDict): + redditClientId: str + redditClientSecret: str + redditPassword: str + username: str + + +class Redis(TypedDict): + caCerts: str + caPath: str + certReqs: str + clusterMode: bool + host: str + port: int + pwd: str + ssl: bool + sslCertfile: str + sslKeyfile: str + username: str + + +class RedisCloud(TypedDict): + apiKey: str + secretKey: NotRequired[str] + + +class Ref(TypedDict): + apiKey: str + + +class Render(TypedDict): + apiKey: str + + +class Resend(TypedDict): + apiKey: NotRequired[str] + replyTo: str + """ + comma separated list of reply to email addresses + """ + sender: str + """ + sender email address + """ + + +class Risken(TypedDict): + accessToken: str + url: str + + +class Root(TypedDict): + apiAccessToken: str + + +class Rube(TypedDict): + apiKey: str + + +class RustMcpFilesystem(TypedDict): + allowWrite: bool + """ + Enable read/write mode. If false, the app operates in read-only mode. + """ + allowedDirectories: List[str] + """ + List of directories that rust-mcp-filesystem can access. + """ + enableRoots: bool + """ + Enable dynamic directory access control via MCP client-side Roots. + """ + + +class SchemacrawlerAi(TypedDict): + generalInfoLevel: str + """ + --info-level How much database metadata to retrieve + """ + generalLogLevel: NotRequired[str] + schcrwlrDatabasePassword: NotRequired[str] + schcrwlrDatabaseUser: NotRequired[str] + serverConnectionDatabase: NotRequired[str] + """ + --database Database to connect to (optional) + """ + serverConnectionHost: NotRequired[str] + """ + --host Database host (optional) + """ + serverConnectionPort: NotRequired[int] + """ + --port Database port (optional) + """ + serverConnectionServer: str + """ + --server SchemaCrawler database plugin + """ + urlConnectionJdbcUrl: str + """ + --url JDBC URL for database connection + """ + volumeHostShare: str + """ + Host volume to map within the Docker container + """ + + +class Scrapegraph(TypedDict): + sgaiApiKey: str + + +class Scrapezy(TypedDict): + apiKey: str + + +class Sentry(TypedDict): + authToken: str + + +class Sequa(TypedDict): + apiKey: str + mcpServerUrl: str + + +class ShortIo(TypedDict): + shortIoApiKey: str + + +class Singlestore(TypedDict): + mcpApiKey: str + + +class Slack(TypedDict): + botToken: NotRequired[str] + channelIds: NotRequired[str] + teamId: str + + +class Smartbear(TypedDict): + apiHubApiKey: str + bugsnagApiKey: str + bugsnagAuthToken: str + bugsnagEndpoint: str + pactBrokerBaseUrl: str + pactBrokerPassword: str + pactBrokerToken: str + pactBrokerUsername: str + reflectApiToken: str + + +class Sonarqube(TypedDict): + org: str + """ + Organization key for SonarQube Cloud, not required for SonarQube Server or Community Build + """ + token: str + url: str + """ + URL of the SonarQube instance, to provide only for SonarQube Server or Community Build + """ + + +class Stackgen(TypedDict): + token: NotRequired[str] + url: str + """ + URL of your StackGen instance + """ + + +class Stackhawk(TypedDict): + apiKey: str + + +class Stripe(TypedDict): + secretKey: str + + +class Supadata(TypedDict): + apiKey: str + + +class Suzieq(TypedDict): + apiEndpoint: str + apiKey: str + + +class Tavily(TypedDict): + apiKey: str + + +class Teamwork(TypedDict): + twMcpBearerToken: str + + +class Telnyx(TypedDict): + apiKey: str + + +class Tembo(TypedDict): + apiKey: str + + +class TextToGraphql(TypedDict): + graphqlApiKey: str + graphqlAuthType: str + """ + Authentication method for GraphQL API + """ + graphqlEndpoint: str + modelName: str + """ + OpenAI model to use + """ + modelTemperature: float + """ + Model temperature for responses + """ + openaiApiKey: str + + +class Tigris(TypedDict): + awsAccessKeyId: str + awsEndpointUrlS3: str + awsSecretAccessKey: NotRequired[str] + + +class Triplewhale(TypedDict): + apiKey: str + + +class UnrealEngine(TypedDict): + logLevel: NotRequired[str] + """ + Logging level + """ + ueHost: str + """ + Unreal Engine host address. Use: host.docker.internal for local UE on Windows/Mac Docker, 127.0.0.1 for Linux without Docker, or actual IP address (e.g., 192.168.1.100) for remote UE + """ + ueRcHttpPort: str + """ + Remote Control HTTP port + """ + ueRcWsPort: str + """ + Remote Control WebSocket port + """ + + +class Veyrax(TypedDict): + apiKey: str + + +class Wayfound(TypedDict): + mcpApiKey: str + + +class Webflow(TypedDict): + token: str + + +class WolframAlpha(TypedDict): + wolframApiKey: str + + +class ZerodhaKite(TypedDict): + kiteAccessToken: NotRequired[str] + """ + Access token obtained after OAuth authentication (optional - can be generated at runtime) + """ + kiteApiKey: str + """ + Your Kite Connect API key from the developer console + """ + kiteApiSecret: NotRequired[str] + kiteRedirectUrl: NotRequired[str] + """ + OAuth redirect URL configured in your Kite Connect app + """ + + +class McpServer(TypedDict): + airtable: NotRequired[Airtable] + """ + Provides AI assistants with direct access to Airtable bases, allowing them to read schemas, query records, and interact with your Airtable data. Supports listing bases, retrieving table structures, and searching through records to help automate workflows and answer questions about your organized data. + """ + aks: NotRequired[Aks] + """ + Azure Kubernetes Service (AKS) official MCP server. + """ + apiGateway: NotRequired[ApiGateway] + """ + A universal MCP (Model Context Protocol) server to integrate any API with Claude Desktop using only Docker configurations. + """ + apify: NotRequired[Apify] + """ + Apify is the world's largest marketplace of tools for web scraping, data extraction, and web automation. You can extract structured data from social media, e-commerce, search engines, maps, travel sites, or any other website. + """ + arxiv: NotRequired[Arxiv] + """ + The ArXiv MCP Server provides a comprehensive bridge between AI assistants and arXiv's research repository through the Model Context Protocol (MCP). Features: • Search arXiv papers with advanced filtering • Download and store papers locally as markdown • Read and analyze paper content • Deep research analysis prompts • Local paper management and storage • Enhanced tool descriptions optimized for local AI models • Docker MCP Gateway compatible with detailed context Perfect for researchers, academics, and AI assistants conducting literature reviews and research analysis. **Recent Update**: Enhanced tool descriptions specifically designed to resolve local AI model confusion and improve Docker MCP Gateway compatibility. + """ + astGrep: NotRequired[AstGrep] + """ + ast-grep is a fast and polyglot tool for code structural search, lint, rewriting at large scale. + """ + astraDb: NotRequired[AstraDb] + """ + An MCP server for Astra DB workloads. + """ + astroDocs: NotRequired[Dict[str, Any]] + """ + Access the latest Astro web framework documentation, guides, and API references. + """ + atlan: NotRequired[Atlan] + """ + MCP server for interacting with Atlan services including asset search, updates, and lineage traversal for comprehensive data governance and discovery. + """ + atlasDocs: NotRequired[AtlasDocs] + """ + Provide LLMs hosted, clean markdown documentation of libraries and frameworks. + """ + atlassian: NotRequired[Atlassian] + """ + Tools for Atlassian products (Confluence and Jira). This integration supports both Atlassian Cloud and Jira Server/Data Center deployments. + """ + audienseInsights: NotRequired[AudienseInsights] + """ + Audiense Insights MCP Server is a server based on the Model Context Protocol (MCP) that allows Claude and other MCP-compatible clients to interact with your Audiense Insights account. + """ + awsCdk: NotRequired[Dict[str, Any]] + """ + AWS Cloud Development Kit (CDK) best practices, infrastructure as code patterns, and security compliance with CDK Nag. + """ + awsCore: NotRequired[Dict[str, Any]] + """ + Starting point for using the awslabs MCP servers. + """ + awsDiagram: NotRequired[Dict[str, Any]] + """ + Seamlessly create diagrams using the Python diagrams package DSL. This server allows you to generate AWS diagrams, sequence diagrams, flow diagrams, and class diagrams using Python code. + """ + awsDocumentation: NotRequired[Dict[str, Any]] + """ + Tools to access AWS documentation, search for content, and get recommendations. + """ + awsKbRetrievalServer: NotRequired[AwsKbRetrievalServer] + """ + An MCP server implementation for retrieving information from the AWS Knowledge Base using the Bedrock Agent Runtime. + """ + awsTerraform: NotRequired[Dict[str, Any]] + """ + Terraform on AWS best practices, infrastructure as code patterns, and security compliance with Checkov. + """ + azure: NotRequired[Dict[str, Any]] + """ + The Azure MCP Server, bringing the power of Azure to your agents. + """ + beagleSecurity: NotRequired[BeagleSecurity] + """ + Connects with the Beagle Security backend using a user token to manage applications, run automated security tests, track vulnerabilities across environments, and gain intelligence from Application and API vulnerability data. + """ + bitrefill: NotRequired[Bitrefill] + """ + A Model Context Protocol Server connector for Bitrefill public API, to enable AI agents to search and shop on Bitrefill. + """ + box: NotRequired[Box] + """ + An MCP server capable of interacting with the Box API. + """ + brave: NotRequired[Brave] + """ + Search the Web for pages, images, news, videos, and more using the Brave Search API. + """ + browserbase: NotRequired[Browserbase] + """ + Allow LLMs to control a browser with Browserbase and Stagehand for AI-powered web automation, intelligent data extraction, and screenshot capture. + """ + buildkite: NotRequired[Buildkite] + """ + Buildkite MCP lets agents interact with Buildkite Builds, Jobs, Logs, Packages and Test Suites. + """ + camunda: NotRequired[Camunda] + """ + Tools to interact with the Camunda 7 Community Edition Engine using the Model Context Protocol (MCP). Whether you're automating workflows, querying process instances, or integrating with external systems, Camunda MCP Server is your agentic solution for seamless interaction with Camunda. + """ + cdataConnectcloud: NotRequired[CdataConnectcloud] + """ + This fully functional MCP Server allows you to connect to any data source in Connect Cloud from Claude Desktop. + """ + charmhealth: NotRequired[Charmhealth] + """ + An MCP server for CharmHealth EHR that allows LLMs and MCP clients to interact with patient records, encounters, and practice information. + """ + chroma: NotRequired[Chroma] + """ + A Model Context Protocol (MCP) server implementation that provides database capabilities for Chroma. + """ + circleci: NotRequired[Circleci] + """ + A specialized server implementation for the Model Context Protocol (MCP) designed to integrate with CircleCI's development workflow. This project serves as a bridge between CircleCI's infrastructure and the Model Context Protocol, enabling enhanced AI-powered development experiences. + """ + clickhouse: NotRequired[Clickhouse] + """ + Official ClickHouse MCP Server. + """ + close: NotRequired[Close] + """ + Streamline sales processes with integrated calling, email, SMS, and automated workflows for small and scaling businesses. + """ + cloudRun: NotRequired[CloudRun] + """ + MCP server to deploy apps to Cloud Run. + """ + cloudflareDocs: NotRequired[Dict[str, Any]] + """ + Access the latest documentation on Cloudflare products such as Workers, Pages, R2, D1, KV. + """ + cockroachdb: NotRequired[Cockroachdb] + """ + Enable AI agents to manage, monitor, and query CockroachDB using natural language. Perform complex database operations, cluster management, and query execution seamlessly through AI-driven workflows. Integrate effortlessly with MCP clients for scalable and high-performance data operations. + """ + codeInterpreter: NotRequired[Dict[str, Any]] + """ + A Python-based execution tool that mimics a Jupyter notebook environment. It accepts code snippets, executes them, and maintains state across sessions — preserving variables, imports, and past results. Ideal for iterative development, debugging, or code execution. + """ + context7: NotRequired[Dict[str, Any]] + """ + Context7 MCP Server -- Up-to-date code documentation for LLMs and AI code editors. + """ + couchbase: NotRequired[Couchbase] + """ + Couchbase is a distributed document database with a powerful search engine and in-built operational and analytical capabilities. + """ + cylera: NotRequired[Cylera] + """ + Brings context about device inventory, threats, risks and utilization powered by the Cylera Partner API into an LLM. + """ + cyreslabAiShodan: NotRequired[CyreslabAiShodan] + """ + A Model Context Protocol server that provides access to Shodan API functionality. + """ + dappier: NotRequired[Dappier] + """ + Enable fast, free real-time web search and access premium data from trusted media brands—news, financial markets, sports, entertainment, weather, and more. Build powerful AI agents with Dappier. + """ + dappierRemote: NotRequired[DappierRemote] + """ + Enable fast, free real-time web search and access premium data from trusted media brands—news, financial markets, sports, entertainment, weather, and more. Build powerful AI agents with Dappier. + """ + dart: NotRequired[Dart] + """ + Dart AI Model Context Protocol (MCP) server. + """ + databaseServer: NotRequired[DatabaseServer] + """ + Comprehensive database server supporting PostgreSQL, MySQL, and SQLite with natural language SQL query capabilities. Enables AI agents to interact with databases through both direct SQL and natural language queries. + """ + databutton: NotRequired[Dict[str, Any]] + """ + Databutton MCP Server. + """ + deepwiki: NotRequired[Dict[str, Any]] + """ + Tools for fetching and asking questions about GitHub repositories. + """ + descope: NotRequired[Descope] + """ + The Descope Model Context Protocol (MCP) server provides an interface to interact with Descope's Management APIs, enabling the search and retrieval of project-related information. + """ + desktopCommander: NotRequired[DesktopCommander] + """ + Search, update, manage files and run terminal commands with AI. + """ + devhubCms: NotRequired[DevhubCms] + """ + DevHub CMS LLM integration through the Model Context Protocol. + """ + discord: NotRequired[Discord] + """ + Interact with the Discord platform. + """ + dockerhub: NotRequired[Dockerhub] + """ + Docker Hub official MCP server. + """ + dodoPayments: NotRequired[DodoPayments] + """ + Tools for cross-border payments, taxes, and compliance. + """ + dreamfactory: NotRequired[Dreamfactory] + """ + DreamFactory is a REST API generation platform with support for hundreds of data sources, including Microsoft SQL Server, MySQL, PostgreSQL, and MongoDB. The DreamFactory MCP Server makes it easy for users to securely interact with their data sources via an MCP client. + """ + duckduckgo: NotRequired[Dict[str, Any]] + """ + A Model Context Protocol (MCP) server that provides web search capabilities through DuckDuckGo, with additional features for content fetching and parsing. + """ + dynatrace: NotRequired[Dynatrace] + """ + This MCP Server allows interaction with the Dynatrace observability platform, brining real-time observability data directly into your development workflow. + """ + e2b: NotRequired[E2b] + """ + Giving Claude ability to run code with E2B via MCP (Model Context Protocol). + """ + edubase: NotRequired[Edubase] + """ + The EduBase MCP server enables Claude and other LLMs to interact with EduBase's comprehensive e-learning platform through the Model Context Protocol (MCP). + """ + effect: NotRequired[Dict[str, Any]] + """ + Tools and resources for writing Effect code in Typescript. + """ + elasticsearch: NotRequired[Elasticsearch] + """ + Interact with your Elasticsearch indices through natural language conversations. + """ + elevenlabs: NotRequired[Elevenlabs] + """ + Official ElevenLabs Model Context Protocol (MCP) server that enables interaction with powerful Text to Speech and audio processing APIs. + """ + everart: NotRequired[Everart] + """ + Image generation server using EverArt's API. + """ + exa: NotRequired[Exa] + """ + Exa MCP for web search and web crawling!. + """ + explorium: NotRequired[Explorium] + """ + Discover companies, contacts, and business insights—powered by dozens of trusted external data sources. + """ + fetch: NotRequired[Dict[str, Any]] + """ + Fetches a URL from the internet and extracts its contents as markdown. + """ + fibery: NotRequired[Fibery] + """ + Interact with your Fibery workspace. + """ + filesystem: NotRequired[Filesystem] + """ + Local filesystem access with configurable allowed paths. + """ + findADomain: NotRequired[Dict[str, Any]] + """ + Tools for finding domain names. + """ + firecrawl: NotRequired[Firecrawl] + """ + 🔥 Official Firecrawl MCP Server - Adds powerful web scraping and search to Cursor, Claude and any other LLM clients. + """ + firewalla: NotRequired[Firewalla] + """ + Real-time network monitoring, security analysis, and firewall management through 28 specialized tools. Access security alerts, network flows, device status, and firewall rules directly from your Firewalla device. + """ + flexprice: NotRequired[Flexprice] + """ + Official flexprice MCP Server. + """ + git: NotRequired[Git] + """ + Git repository interaction and automation. + """ + github: NotRequired[Github] + """ + Tools for interacting with the GitHub API, enabling file operations, repository management, search functionality, and more. + """ + githubChat: NotRequired[GithubChat] + """ + A Model Context Protocol (MCP) for analyzing and querying GitHub repositories using the GitHub Chat API. + """ + githubOfficial: NotRequired[GithubOfficial] + """ + Official GitHub MCP Server, by GitHub. Provides seamless integration with GitHub APIs, enabling advanced automation and interaction capabilities for developers and tools. + """ + gitlab: NotRequired[Gitlab] + """ + MCP Server for the GitLab API, enabling project management, file operations, and more. + """ + gitmcp: NotRequired[Dict[str, Any]] + """ + Tools for interacting with Git repositories. + """ + glif: NotRequired[Glif] + """ + Easily run glif.app AI workflows inside your LLM: image generators, memes, selfies, and more. Glif supports all major multimedia AI models inside one app. + """ + gmail: NotRequired[Gmail] + """ + A Model Context Protocol server for Gmail operations using IMAP/SMTP with app password authentication. Supports listing messages, searching emails, and sending messages. To create your app password, visit your Google Account settings under Security > App Passwords. Or visit the link https://myaccount.google.com/apppasswords. + """ + googleMaps: NotRequired[GoogleMaps] + """ + Tools for interacting with the Google Maps API. + """ + googleMapsComprehensive: NotRequired[GoogleMapsComprehensive] + """ + Complete Google Maps integration with 8 tools including geocoding, places search, directions, elevation data, and more using Google's latest APIs. + """ + grafana: NotRequired[Grafana] + """ + MCP server for Grafana. + """ + gyazo: NotRequired[Gyazo] + """ + Official Model Context Protocol server for Gyazo. + """ + hackernews: NotRequired[Dict[str, Any]] + """ + A Model Context Protocol (MCP) server that provides access to Hacker News stories, comments, and user data, with support for search and content retrieval. + """ + hackle: NotRequired[Hackle] + """ + Model Context Protocol server for Hackle. + """ + handwritingOcr: NotRequired[HandwritingOcr] + """ + Model Context Protocol (MCP) Server for Handwriting OCR. + """ + hdx: NotRequired[Hdx] + """ + HDX MCP Server provides access to humanitarian data through the Humanitarian Data Exchange (HDX) API - https://data.humdata.org/hapi. This server offers 33 specialized tools for retrieving humanitarian information including affected populations (refugees, IDPs, returnees), baseline demographics, food security indicators, conflict data, funding information, and operational presence across hundreds of countries and territories. See repository for instructions on getting a free HDX_APP_INDENTIFIER for access. + """ + heroku: NotRequired[Heroku] + """ + Heroku Platform MCP Server using the Heroku CLI. + """ + hostinger: NotRequired[Hostinger] + """ + Interact with Hostinger services over the Hostinger API. + """ + hoverfly: NotRequired[Hoverfly] + """ + A Model Context Protocol (MCP) server that exposes Hoverfly as a programmable tool for AI assistants like Cursor, Claude, GitHub Copilot, and others supporting MCP. It enables dynamic mocking of third-party APIs to unblock development, automate testing, and simulate unavailable services during integration. + """ + hubspot: NotRequired[Hubspot] + """ + Unite marketing, sales, and customer service with AI-powered automation, lead management, and comprehensive analytics. + """ + huggingFace: NotRequired[Dict[str, Any]] + """ + Tools for interacting with Hugging Face models, datasets, research papers, and more. + """ + hummingbot: NotRequired[Hummingbot] + """ + Hummingbot MCP is an open-source toolset that lets you control and monitor your Hummingbot trading bots through AI-powered commands and automation. + """ + husqvarnaAutomower: NotRequired[HusqvarnaAutomower] + """ + MCP Server for huqsvarna automower. + """ + hyperbrowser: NotRequired[Hyperbrowser] + """ + A MCP server implementation for hyperbrowser. + """ + hyperspell: NotRequired[Hyperspell] + """ + Hyperspell MCP Server. + """ + iaptic: NotRequired[Iaptic] + """ + Model Context Protocol server for interacting with iaptic. + """ + inspektorGadget: NotRequired[InspektorGadget] + """ + AI interface to troubleshoot and observe Kubernetes/Container workloads. + """ + javadocs: NotRequired[Dict[str, Any]] + """ + Access to Java, Kotlin, and Scala library documentation. + """ + jetbrains: NotRequired[Jetbrains] + """ + A model context protocol server to work with JetBrains IDEs: IntelliJ, PyCharm, WebStorm, etc. Also, works with Android Studio. + """ + kafkaSchemaReg: NotRequired[KafkaSchemaReg] + """ + Comprehensive MCP server for Kafka Schema Registry operations. Features multi-registry support, schema contexts, migration tools, OAuth authentication, and 57+ tools for complete schema management. Supports SLIM_MODE for optimal performance. + """ + kagisearch: NotRequired[Kagisearch] + """ + The Official Model Context Protocol (MCP) server for Kagi search & other tools. + """ + keboola: NotRequired[Keboola] + """ + Keboola MCP Server is an open-source bridge between your Keboola project and modern AI tools. + """ + kong: NotRequired[Kong] + """ + A Model Context Protocol (MCP) server for interacting with Kong Konnect APIs, allowing AI assistants to query and analyze Kong Gateway configurations, traffic, and analytics. + """ + kubectl: NotRequired[Kubectl] + """ + MCP Server that enables AI assistants to interact with Kubernetes clusters via kubectl operations. + """ + kubernetes: NotRequired[Kubernetes] + """ + Connect to a Kubernetes cluster and manage it. + """ + lara: NotRequired[Lara] + """ + Connect to Lara Translate API, enabling powerful translation capabilities with support for language detection and context-aware translations. + """ + line: NotRequired[Line] + """ + MCP server that integrates the LINE Messaging API to connect an AI Agent to the LINE Official Account. + """ + linkedin: NotRequired[Linkedin] + """ + This MCP server allows Claude and other AI assistants to access your LinkedIn. Scrape LinkedIn profiles and companies, get your recommended jobs, and perform job searches. Set your li_at LinkedIn cookie to use this server. + """ + llmtxt: NotRequired[Dict[str, Any]] + """ + Discovers and retrieves llms.txt from websites. + """ + maestro: NotRequired[Maestro] + """ + A Model Context Protocol (MCP) server exposing Bitcoin blockchain data through the Maestro API platform. Provides tools to explore blocks, transactions, addresses, inscriptions, runes, and other metaprotocol data. + """ + manifold: NotRequired[Dict[str, Any]] + """ + Tools for accessing the Manifold Markets online prediction market platform. + """ + mapbox: NotRequired[Mapbox] + """ + Transform any AI agent into a geospatially-aware system with Mapbox APIs. Provides geocoding, POI search, routing, travel time matrices, isochrones, and static map generation. + """ + mapboxDevkit: NotRequired[MapboxDevkit] + """ + Direct access to Mapbox developer APIs for AI assistants. Enables style management, token management, GeoJSON preview, and other developer tools for building Mapbox applications. + """ + markdownify: NotRequired[Markdownify] + """ + A Model Context Protocol server for converting almost anything to Markdown. + """ + markitdown: NotRequired[Markitdown] + """ + A lightweight MCP server for calling MarkItDown. + """ + mavenTools: NotRequired[Dict[str, Any]] + """ + JVM dependency intelligence for any build tool using Maven Central Repository. Includes Context7 integration for upgrade documentation and guidance. + """ + memory: NotRequired[Dict[str, Any]] + """ + Knowledge graph-based persistent memory system. + """ + mercadoLibre: NotRequired[MercadoLibre] + """ + Provides access to Mercado Libre E-Commerce API. + """ + mercadoPago: NotRequired[MercadoPago] + """ + Provides access to Mercado Pago Marketplace API. + """ + metabase: NotRequired[Metabase] + """ + A comprehensive MCP server for Metabase with 70+ tools. + """ + minecraftWiki: NotRequired[Dict[str, Any]] + """ + A MCP Server for browsing the official Minecraft Wiki!. + """ + mongodb: NotRequired[Mongodb] + """ + A Model Context Protocol server to connect to MongoDB databases and MongoDB Atlas Clusters. + """ + multiversxMx: NotRequired[MultiversxMx] + """ + MCP Server for MultiversX. + """ + nasdaqDataLink: NotRequired[NasdaqDataLink] + """ + MCP server to interact with the data feeds provided by the Nasdaq Data Link. Developed by the community and maintained by Stefano Amorelli. + """ + needle: NotRequired[Needle] + """ + Production-ready RAG service to search and retrieve data from your documents. + """ + neo4jCloudAuraApi: NotRequired[Neo4jCloudAuraApi] + """ + Manage Neo4j Aura database instances through the Neo4j Aura API. + """ + neo4jCypher: NotRequired[Neo4jCypher] + """ + Interact with Neo4j using Cypher graph queries. + """ + neo4jDataModeling: NotRequired[Neo4jDataModeling] + """ + MCP server that assists in creating, validating and visualizing graph data models. + """ + neo4jMemory: NotRequired[Neo4jMemory] + """ + Provide persistent memory capabilities through Neo4j graph database integration. + """ + neon: NotRequired[Neon] + """ + MCP server for interacting with Neon Management API and databases. + """ + nodeCodeSandbox: NotRequired[Dict[str, Any]] + """ + A Node.js–based Model Context Protocol server that spins up disposable Docker containers to execute arbitrary JavaScript. + """ + notion: NotRequired[Notion] + """ + Official Notion MCP Server. + """ + novita: NotRequired[Dict[str, Any]] + """ + Seamless interaction with Novita AI platform resources. + """ + npmSentinel: NotRequired[Dict[str, Any]] + """ + MCP server that enables intelligent NPM package analysis powered by AI. + """ + obsidian: NotRequired[Obsidian] + """ + MCP server that interacts with Obsidian via the Obsidian rest API community plugin. + """ + oktaMcpFctr: NotRequired[OktaMcpFctr] + """ + Secure Okta identity and access management via Model Context Protocol (MCP). Access Okta users, groups, applications, logs, and policies through AI assistants with enterprise-grade security. + """ + omi: NotRequired[Omi] + """ + A Model Context Protocol server for Omi interaction and automation. This server provides tools to read, search, and manipulate Memories and Conversations. + """ + onlyofficeDocspace: NotRequired[OnlyofficeDocspace] + """ + ONLYOFFICE DocSpace is a room-based collaborative platform which allows organizing a clear file structure depending on users' needs or project goals. + """ + openapi: NotRequired[Openapi] + """ + Fetch, validate, and generate code or curl from any OpenAPI or Swagger spec - all from a single URL. + """ + openapiSchema: NotRequired[OpenapiSchema] + """ + OpenAPI Schema Model Context Protocol Server. + """ + openbnbAirbnb: NotRequired[Dict[str, Any]] + """ + MCP Server for searching Airbnb and get listing details. + """ + openmesh: NotRequired[Dict[str, Any]] + """ + Discover and connect to a curated marketplace of MCP servers for extending AI agent capabilities. + """ + openweather: NotRequired[Openweather] + """ + A simple MCP service that provides current weather and 5-day forecast using the free OpenWeatherMap API. + """ + openzeppelinCairo: NotRequired[Dict[str, Any]] + """ + Access to OpenZeppelin Cairo Contracts. + """ + openzeppelinSolidity: NotRequired[Dict[str, Any]] + """ + Access to OpenZeppelin Solidity Contracts. + """ + openzeppelinStellar: NotRequired[Dict[str, Any]] + """ + Access to OpenZeppelin Stellar Contracts. + """ + openzeppelinStylus: NotRequired[Dict[str, Any]] + """ + Access to OpenZeppelin Stylus Contracts. + """ + opik: NotRequired[Opik] + """ + Model Context Protocol (MCP) implementation for Opik enabling seamless IDE integration and unified access to prompts, projects, traces, and metrics. + """ + opine: NotRequired[Opine] + """ + A Model Context Protocol (MCP) server for querying deals and evaluations from the Opine CRM API. + """ + oracle: NotRequired[Oracle] + """ + Connect to Oracle databases via MCP, providing secure read-only access with support for schema exploration, query execution, and metadata inspection. + """ + ospMarketingTools: NotRequired[Dict[str, Any]] + """ + A Model Context Protocol (MCP) server that empowers LLMs to use some of Open Srategy Partners' core writing and product marketing techniques. + """ + oxylabs: NotRequired[Oxylabs] + """ + A Model Context Protocol (MCP) server that enables AI assistants like Claude to seamlessly access web data through Oxylabs' powerful web scraping technology. + """ + paperSearch: NotRequired[Dict[str, Any]] + """ + A MCP for searching and downloading academic papers from multiple sources like arXiv, PubMed, bioRxiv, etc. + """ + perplexityAsk: NotRequired[PerplexityAsk] + """ + Connector for Perplexity API, to enable real-time, web-wide research. + """ + pia: NotRequired[Pia] + """ + An MCP server to help make U.S. Government open datasets AI-friendly. + """ + pinecone: NotRequired[Pinecone] + """ + Pinecone Assistant MCP server. + """ + playwright: NotRequired[Playwright] + """ + Playwright Model Context Protocol Server - Tool to automate Browsers and APIs in Claude Desktop, Cline, Cursor IDE and More 🔌. + """ + pluggedinMcpProxy: NotRequired[PluggedinMcpProxy] + """ + A unified MCP proxy that aggregates multiple MCP servers into one interface, enabling seamless tool discovery and management across all your AI interactions. Manage all your MCP servers from a single connection point with RAG capabilities and real-time notifications. + """ + polarSignals: NotRequired[PolarSignals] + """ + MCP server for Polar Signals Cloud continuous profiling platform, enabling AI assistants to analyze CPU performance, memory usage, and identify optimization opportunities in production systems. + """ + pomodash: NotRequired[Pomodash] + """ + Connect your AI assistant to PomoDash for seamless task and project management. + """ + postgres: NotRequired[Postgres] + """ + Connect with read-only access to PostgreSQL databases. This server enables LLMs to inspect database schemas and execute read-only queries. + """ + postman: NotRequired[Postman] + """ + Postman's MCP server connects AI agents, assistants, and chatbots directly to your APIs on Postman. Use natural language to prompt AI to automate work across your Postman collections, environments, workspaces, and more. + """ + prefEditor: NotRequired[Dict[str, Any]] + """ + Pref Editor is a tool for viewing and editing Android app preferences during development. + """ + prometheus: NotRequired[Prometheus] + """ + A Model Context Protocol (MCP) server that enables AI assistants to query and analyze Prometheus metrics through standardized interfaces. Connect to your Prometheus instance to retrieve metrics, perform queries, and gain insights into your system's performance and health. + """ + puppeteer: NotRequired[Dict[str, Any]] + """ + Browser automation and web scraping using Puppeteer. + """ + pythonRefactoring: NotRequired[Dict[str, Any]] + """ + Educational Python refactoring assistant that provides guided suggestions for AI assistants. Features: • Step-by-step refactoring instructions without modifying code • Comprehensive code analysis using professional tools (Rope, Radon, Vulture, Jedi, LibCST, Pyrefly) • Educational approach teaching refactoring patterns through guided practice • Support for both guide-only and apply-changes modes • Identifies long functions, high complexity, dead code, and type issues • Provides precise line numbers and specific refactoring instructions • Compatible with all AI assistants (Claude, GPT, Cursor, Continue, etc.) Perfect for developers learning refactoring patterns while maintaining full control over code changes. Acts as a refactoring mentor rather than an automated code modifier. + """ + quantconnect: NotRequired[Quantconnect] + """ + The QuantConnect MCP Server is a bridge for AIs (such as Claude and OpenAI o3 Pro) to interact with our cloud platform. When equipped with our MCP, the AI can perform tasks on your behalf through our API such as updating projects, writing strategies, backtesting, and deploying strategies to production live-trading. + """ + ramparts: NotRequired[Dict[str, Any]] + """ + A comprehensive security scanner for MCP servers with YARA rules and static analysis capabilities. + """ + razorpay: NotRequired[Razorpay] + """ + Razorpay's Official MCP Server. + """ + reddit: NotRequired[Reddit] + """ + A comprehensive Model Context Protocol (MCP) server for Reddit integration. This server enables AI agents to interact with Reddit programmatically through a standardized interface. + """ + redis: NotRequired[Redis] + """ + Access to Redis database operations. + """ + redisCloud: NotRequired[RedisCloud] + """ + MCP Server for Redis Cloud's API, allowing you to manage your Redis Cloud resources using natural language. + """ + ref: NotRequired[Ref] + """ + Ref powerful search tool connets your coding tools with documentation context. It includes an up-to-date index of public documentation and it can ingest your private documentation (eg. GitHub repos, PDFs) as well. + """ + remote: NotRequired[Dict[str, Any]] + """ + Tools for finding remote MCP servers. + """ + render: NotRequired[Render] + """ + Interact with your Render resources via LLMs. + """ + resend: NotRequired[Resend] + """ + Send emails directly from Cursor with this email sending MCP server. + """ + risken: NotRequired[Risken] + """ + RISKEN's official MCP Server. + """ + root: NotRequired[Root] + """ + MCP server that provides container image vulnerability scanning and remediation capabilities through Root.io. + """ + ros2: NotRequired[Dict[str, Any]] + """ + Python server implementing Model Context Protocol (MCP) for ROS2. + """ + rube: NotRequired[Rube] + """ + Access to Rube's catalog of remote MCP servers. + """ + rustMcpFilesystem: NotRequired[RustMcpFilesystem] + """ + The Rust MCP Filesystem is a high-performance, asynchronous, and lightweight Model Context Protocol (MCP) server built in Rust for secure and efficient filesystem operations. Designed with security in mind, it operates in read-only mode by default and restricts clients from updating allowed directories via MCP Roots unless explicitly enabled, ensuring robust protection against unauthorized access. Leveraging asynchronous I/O, it delivers blazingly fast performance with a minimal resource footprint. Optimized for token efficiency, the Rust MCP Filesystem enables large language models (LLMs) to precisely target searches and edits within specific sections of large files and restrict operations by file size range, making it ideal for efficient file exploration, automation, and system integration. + """ + schemacrawlerAi: NotRequired[SchemacrawlerAi] + """ + The SchemaCrawler AI MCP Server enables natural language interaction with your database schema using an MCP client in "Agent" mode. It allows users to explore tables, columns, foreign keys, triggers, stored procedures and more simply by asking questions like "Explain the code for the interest calculation stored procedure". You can also ask it to help with SQL, since it knows your schema. This is ideal for developers, DBAs, and data analysts who want to streamline schema comprehension and query development without diving into dense documentation. + """ + schoginiMcpImageBorder: NotRequired[Dict[str, Any]] + """ + This adds a border to an image and returns base64 encoded image. + """ + scrapegraph: NotRequired[Scrapegraph] + """ + ScapeGraph MCP Server. + """ + scrapezy: NotRequired[Scrapezy] + """ + A Model Context Protocol server for Scrapezy that enables AI models to extract structured data from websites. + """ + securenoteLink: NotRequired[Dict[str, Any]] + """ + SecureNote.link MCP Server - allowing AI agents to securely share sensitive information through end-to-end encrypted notes. + """ + semgrep: NotRequired[Dict[str, Any]] + """ + MCP server for using Semgrep to scan code for security vulnerabilities. + """ + sentry: NotRequired[Sentry] + """ + A Model Context Protocol server for retrieving and analyzing issues from Sentry.io. This server provides tools to inspect error reports, stacktraces, and other debugging information from your Sentry account. + """ + sequa: NotRequired[Sequa] + """ + Stop stitching context for Copilot and Cursor. With Sequa MCP, your AI tools know your entire codebase and docs out of the box. + """ + sequentialthinking: NotRequired[Dict[str, Any]] + """ + Dynamic and reflective problem-solving through thought sequences. + """ + shortIo: NotRequired[ShortIo] + """ + Access to Short.io's link shortener and analytics tools. + """ + simplechecklist: NotRequired[Dict[str, Any]] + """ + Advanced SimpleCheckList with MCP server and SQLite database for comprehensive task management. Features: • Complete project and task management system • Hierarchical organization (Projects → Groups → Task Lists → Tasks → Subtasks) • SQLite database for data persistence • RESTful API with comprehensive endpoints • MCP protocol compliance for AI assistant integration • Docker-optimized deployment with stability improvements **v1.0.1 Update**: Enhanced Docker stability with improved container lifecycle management. Default mode optimized for containerized deployment with reliable startup and shutdown processes. Perfect for AI assistants managing complex project workflows and task hierarchies. + """ + singlestore: NotRequired[Singlestore] + """ + MCP server for interacting with SingleStore Management API and services. + """ + slack: NotRequired[Slack] + """ + Interact with Slack Workspaces over the Slack API. + """ + smartbear: NotRequired[Smartbear] + """ + MCP server for AI access to SmartBear tools, including BugSnag, Reflect, API Hub, PactFlow. + """ + sonarqube: NotRequired[Sonarqube] + """ + Interact with SonarQube Cloud, Server and Community build over the web API. Analyze code to identify quality and security issues. + """ + sqlite: NotRequired[Dict[str, Any]] + """ + Database interaction and business intelligence capabilities. + """ + stackgen: NotRequired[Stackgen] + """ + AI-powered DevOps assistant for managing cloud infrastructure and applications. + """ + stackhawk: NotRequired[Stackhawk] + """ + A Model Context Protocol (MCP) server for integrating with StackHawk's security scanning platform. Provides security analytics, YAML configuration management, sensitive data/threat surface analysis, and anti-hallucination tools for LLMs. + """ + stripe: NotRequired[Stripe] + """ + Interact with Stripe services over the Stripe API. + """ + supadata: NotRequired[Supadata] + """ + Official Supadata MCP Server - Adds powerful video & web scraping to Cursor, Claude and any other LLM clients. + """ + suzieq: NotRequired[Suzieq] + """ + MCP Server to interact with a SuzieQ network observability instance via its REST API. + """ + taskOrchestrator: NotRequired[Dict[str, Any]] + """ + Model Context Protocol (MCP) server for comprehensive task and feature management, providing AI assistants with a structured, context-efficient way to interact with project data. + """ + tavily: NotRequired[Tavily] + """ + The Tavily MCP server provides seamless interaction with the tavily-search and tavily-extract tools, real-time web search capabilities through the tavily-search tool and Intelligent data extraction from web pages via the tavily-extract tool. + """ + teamwork: NotRequired[Teamwork] + """ + Tools for Teamwork.com products. + """ + telnyx: NotRequired[Telnyx] + """ + Enables interaction with powerful telephony, messaging, and AI assistant APIs. + """ + tembo: NotRequired[Tembo] + """ + MCP server for Tembo Cloud's platform API. + """ + terraform: NotRequired[Dict[str, Any]] + """ + The Terraform MCP Server provides seamless integration with Terraform ecosystem, enabling advanced automation and interaction capabilities for Infrastructure as Code (IaC) development. + """ + textToGraphql: NotRequired[TextToGraphql] + """ + Transform natural language queries into GraphQL queries using an AI agent. Provides schema management, query validation, execution, and history tracking. + """ + tigris: NotRequired[Tigris] + """ + Tigris is a globally distributed S3-compatible object storage service that provides low latency anywhere in the world, enabling developers to store and access any amount of data for a wide range of use cases. + """ + time: NotRequired[Dict[str, Any]] + """ + Time and timezone conversion capabilities. + """ + triplewhale: NotRequired[Triplewhale] + """ + Triplewhale MCP Server. + """ + unrealEngine: NotRequired[UnrealEngine] + """ + A comprehensive Model Context Protocol (MCP) server that enables AI assistants to control Unreal Engine via Remote Control API. Built with TypeScript and designed for game development automation. + """ + veyrax: NotRequired[Veyrax] + """ + VeyraX MCP is the only connection you need to access all your tools in any MCP-compatible environment. + """ + vizro: NotRequired[Dict[str, Any]] + """ + provides tools and templates to create a functioning Vizro chart or dashboard step by step. + """ + vulnNist: NotRequired[Dict[str, Any]] + """ + This MCP server exposes tools to query the NVD/CVE REST API and return formatted text results suitable for LLM consumption via the MCP protocol. It includes automatic query chunking for large date ranges and parallel processing for improved performance. + """ + wayfound: NotRequired[Wayfound] + """ + Wayfound’s MCP server allows business users to govern, supervise, and improve AI Agents. + """ + webflow: NotRequired[Webflow] + """ + Model Context Protocol (MCP) server for the Webflow Data API. + """ + wikipedia: NotRequired[Dict[str, Any]] + """ + A Model Context Protocol (MCP) server that retrieves information from Wikipedia to provide context to LLMs. + """ + wolframAlpha: NotRequired[WolframAlpha] + """ + Connect your chat repl to wolfram alpha computational intelligence. + """ + youtubeTranscript: NotRequired[Dict[str, Any]] + """ + Retrieves transcripts for given YouTube video URLs. + """ + zerodhaKite: NotRequired[ZerodhaKite] + """ + MCP server for Zerodha Kite Connect API - India's leading stock broker trading platform. Execute trades, manage portfolios, and access real-time market data for NSE, BSE, and other Indian exchanges. + """ diff --git a/packages/python-sdk/e2b/sandbox/network.py b/packages/python-sdk/e2b/sandbox/network.py new file mode 100644 index 0000000..7041912 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/network.py @@ -0,0 +1,8 @@ +""" +Network configuration helpers for E2B sandboxes. +""" + +""" +CIDR range that represents all traffic. +""" +ALL_TRAFFIC = "0.0.0.0/0" diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py new file mode 100644 index 0000000..d098286 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -0,0 +1,624 @@ +from dataclasses import dataclass, field +from datetime import datetime +from typing import ( + Any, + Callable, + Dict, + List, + Literal, + Mapping, + Optional, + TypedDict, + Union, + cast, +) + +from typing_extensions import NotRequired, Unpack + +from e2b.api.client.models import ( + ListedSandbox, + SandboxDetail, + SandboxState, +) +from e2b.api.client.models import ( + SandboxLifecycle as ClientSandboxLifecycle, +) +from e2b.api.client.models import ( + SandboxNetworkConfig as ClientSandboxNetworkConfig, +) +from e2b.api.client.models import ( + SandboxNetworkConfigRules, +) +from e2b.api.client.models import ( + SandboxNetworkRule as ClientSandboxNetworkRule, +) +from e2b.api.client.models import ( + SandboxNetworkTransform as ClientSandboxNetworkTransform, +) +from e2b.api.client.models import ( + SandboxNetworkTransformHeaders as ClientSandboxNetworkTransformHeaders, +) +from e2b.api.client.models import ( + SandboxNetworkUpdateConfig, +) +from e2b.api.client.models import ( + SandboxNetworkUpdateConfigRules, +) +from e2b.api.client.types import Unset +from e2b.connection_config import ApiParams +from e2b.sandbox.mcp import McpServer as BaseMcpServer +from e2b.sandbox.network import ALL_TRAFFIC +from e2b.paginator import PaginatorBase + + +class GitHubMcpServerConfig(TypedDict): + """ + Configuration for a GitHub-based MCP server. + """ + + run_cmd: str + """ + Command to run the MCP server. Must start a stdio-compatible server. + """ + install_cmd: NotRequired[str] + """ + Command to install dependencies for the MCP server. Working directory is the root of the github repository. + """ + envs: NotRequired[Dict[str, str]] + """ + Environment variables to set in the MCP process. + """ + + +# Extended MCP server configuration that includes base servers +# and allows dynamic GitHub-based MCP servers with custom run and install commands. +# For GitHub servers, use keys in the format "github/owner/repo" +GitHubMcpServer = Dict[str, Union[GitHubMcpServerConfig, Any]] + +# Union type that combines base MCP servers with GitHub-based servers +McpServer = Union[BaseMcpServer, GitHubMcpServer] + + +class SandboxNetworkTransform(TypedDict): + """ + Transform applied to egress requests matching a :class:`SandboxNetworkRule`. + """ + + headers: NotRequired[Dict[str, str]] + """ + Headers to inject into the outbound request. Values override any headers + already present on the request. + """ + + +class SandboxNetworkRule(TypedDict): + """ + Per-domain rule applied to egress requests. + """ + + transform: NotRequired[SandboxNetworkTransform] + """ + Transform applied to requests matching this rule. + """ + + +SandboxNetworkRules = Dict[str, List[SandboxNetworkRule]] +""" +Map of host (or CIDR / IP) to ordered list of rules applied to outbound +requests for that host. Registering a host here does not allow egress on its +own — the host must also appear in ``SandboxNetworkOpts.allow_out``. +""" + + +class SandboxNetworkRuleInfo(TypedDict): + """ + Per-domain rule as returned by the sandbox info endpoint. Mirrors + :class:`SandboxNetworkRule` but with ``transform`` always materialized to + the static :class:`SandboxNetworkTransform` shape — no callable variant. + """ + + transform: NotRequired[SandboxNetworkTransform] + + +@dataclass(frozen=True) +class SandboxNetworkSelectorContext: + """ + Context passed to ``allow_out``/``deny_out`` callables. + """ + + all_traffic: str + """All traffic sentinel — equivalent to ``"0.0.0.0/0"``.""" + + rules: Mapping[str, List[SandboxNetworkRule]] + """Rules registered in :attr:`SandboxNetworkOpts.rules`.""" + + +SandboxNetworkSelector = Union[ + List[str], + Callable[[SandboxNetworkSelectorContext], List[str]], +] +""" +Egress rule list, either a static list of CIDR blocks / IP addresses / +hostnames, or a callable that receives a :class:`SandboxNetworkSelectorContext` +and returns the same. +""" + + +class SandboxNetworkOpts(TypedDict): + """ + Sandbox network configuration options. + """ + + allow_out: NotRequired[SandboxNetworkSelector] + """ + Allow outbound traffic from the sandbox to the specified addresses. + If ``allow_out`` is not specified, all outbound traffic is allowed. + + Accepts either a static list of CIDR blocks / IP addresses / hostnames, or + a callable that receives a :class:`SandboxNetworkSelectorContext` and + returns the same. ``ctx.all_traffic`` is ``"0.0.0.0/0"``; ``ctx.rules`` is + a read-only view of :attr:`rules`. + + Examples: + - Static list: ``["1.1.1.1", "8.8.8.0/24"]`` + - Allow only rule-registered hosts: + ``lambda ctx: list(ctx.rules.keys())`` + """ + + deny_out: NotRequired[SandboxNetworkSelector] + """ + Deny outbound traffic from the sandbox to the specified addresses. + + Accepts the same shapes as ``allow_out``. + + Examples: + - Static list: ``["1.1.1.1", "8.8.8.0/24"]`` + - Block all egress: ``lambda ctx: [ctx.all_traffic]`` + """ + + rules: NotRequired[SandboxNetworkRules] + """ + Per-domain transform rules applied to matching egress HTTP/HTTPS + requests. Keys are domains (e.g. ``"api.example.com"``); values are + ordered lists of :class:`SandboxNetworkRule`. + + Registering a host here does not allow egress on its own — the host must + also appear in ``allow_out``. Hosts registered here are exposed to the + ``allow_out``/``deny_out`` callables via ``ctx.rules``. + """ + + allow_public_traffic: NotRequired[bool] + """ + Controls whether sandbox URLs should be publicly accessible or require authentication. + Defaults to True. + """ + + mask_request_host: NotRequired[str] + """ + Allows specifying a custom host mask for all sandbox requests. + Supports ${PORT} variable. Defaults to "${PORT}-sandboxid.e2b.app". + + Examples: + - Custom subdomain: `"${PORT}-myapp.example.com"` + """ + + +class SandboxNetworkUpdate(TypedDict, total=False): + """ + Subset of :class:`SandboxNetworkOpts` accepted by ``Sandbox.update_network``. + The update endpoint replaces all egress rules atomically — fields that are + omitted are cleared on the server. + """ + + allow_out: SandboxNetworkSelector + """See :attr:`SandboxNetworkOpts.allow_out`.""" + + deny_out: SandboxNetworkSelector + """See :attr:`SandboxNetworkOpts.deny_out`.""" + + rules: SandboxNetworkRules + """See :attr:`SandboxNetworkOpts.rules`.""" + + allow_internet_access: bool + """ + Allow sandbox to access the internet. When set to ``False``, it behaves the + same as specifying ``deny_out=["0.0.0.0/0"]`` in the network config. + """ + + +class SandboxNetworkInfo(TypedDict, total=False): + """ + Network configuration as returned by the sandbox info endpoint. + Mirrors :class:`SandboxNetworkOpts` but with ``allow_out``/``deny_out`` + always materialized to plain string lists. + """ + + allow_out: List[str] + deny_out: List[str] + rules: Dict[str, List[SandboxNetworkRuleInfo]] + allow_public_traffic: bool + mask_request_host: str + + +class SandboxOnTimeoutPause(TypedDict): + """ + Object form of `on_timeout` that auto-pauses the sandbox when the timeout is + reached, optionally controlling the pause snapshot kind via `keep_memory`. + """ + + action: Literal["pause"] + """Auto-pause the sandbox when the timeout is reached.""" + + keep_memory: NotRequired[bool] + """ + Whether the timeout auto-pause keeps a full memory snapshot. Defaults to `True`. + When `False`, the auto-pause drops the in-memory state and persists only the + filesystem (a filesystem-only snapshot); resuming such a sandbox cold-boots + (reboots) it from disk, losing running processes and open connections. + + Cannot be combined with `auto_resume`: auto-resume wakes a paused sandbox on + inbound traffic by restoring its memory snapshot in place, so the request that + woke it hits an already-running process. A filesystem-only snapshot has no + memory to restore — resuming cold-boots it — so it can't be woken transparently + by traffic and must be resumed explicitly via `connect()`. + """ + + +class SandboxOnTimeoutKill(TypedDict): + """ + Object form of `on_timeout` that kills the sandbox when the timeout is reached. + """ + + action: Literal["kill"] + """Kill the sandbox when the timeout is reached.""" + + +SandboxOnTimeout = Union[ + Literal["pause", "kill"], SandboxOnTimeoutPause, SandboxOnTimeoutKill +] +""" +What should happen to the sandbox when the timeout is reached. Either the bare +action (`"pause"` / `"kill"`) or the object form. The object form is a +discriminated union on `action`: `keep_memory` is only accepted alongside +`action: "pause"`. Passing `keep_memory` with `action: "kill"` is a static type +error. +""" + + +class SandboxLifecycle(TypedDict): + """ + Sandbox lifecycle configuration; defines post-timeout behavior and auto-resume settings. + Defaults to `on_timeout="kill"` and `auto_resume=False`. + """ + + on_timeout: SandboxOnTimeout + """ + What should happen to the sandbox when timeout is reached. `"kill"` terminates + the sandbox; `"pause"` pauses it for later resume. Accepts either the bare + action or an object `{"action": "pause", "keep_memory": ...}` / + `{"action": "kill"}` to also control the pause snapshot kind. Defaults to + `"kill"`. + """ + + auto_resume: NotRequired[bool] + """ + Whether activity should cause the sandbox to resume when paused. Defaults to `False`. + Can be `True` only when `on_timeout` is `pause`. Not supported when + `keep_memory` is `False` (a filesystem-only snapshot must be resumed + explicitly via `connect()`). + """ + + +class SandboxInfoLifecycle(TypedDict): + """ + Sandbox lifecycle configuration returned by sandbox info. + """ + + on_timeout: Literal["pause", "kill"] + """ + What should happen to the sandbox when timeout is reached. + """ + + auto_resume: bool + """ + Whether activity should cause the sandbox to resume when paused. + """ + + +def _resolve_network_selector( + selector: Optional[SandboxNetworkSelector], + rules: Mapping[str, List[SandboxNetworkRule]], +) -> Optional[List[str]]: + if selector is None: + return None + + if callable(selector): + ctx = SandboxNetworkSelectorContext(all_traffic=ALL_TRAFFIC, rules=rules) + return list(selector(ctx)) + + return list(selector) + + +def _build_client_rules(rules: SandboxNetworkRules) -> SandboxNetworkConfigRules: + client_rules = SandboxNetworkConfigRules() + for host, host_rules in rules.items(): + converted: List[ClientSandboxNetworkRule] = [] + for rule in host_rules: + transform = rule.get("transform") + if transform is None: + converted.append(ClientSandboxNetworkRule()) + continue + + client_transform = ClientSandboxNetworkTransform() + headers = transform.get("headers") + if headers: + client_headers = ClientSandboxNetworkTransformHeaders() + client_headers.additional_properties = dict(headers) + client_transform.headers = client_headers + + converted.append(ClientSandboxNetworkRule(transform=client_transform)) + client_rules.additional_properties[host] = converted + + return client_rules + + +def _build_network_egress( + network: Mapping[str, Any], +) -> Dict[str, Any]: + """ + Resolve the shared egress fields (``allow_out`` / ``deny_out`` / per-host + ``rules``) used by both the create and update endpoints. ``rules`` in the + returned dict is the inner ``Dict[host, List[ClientSandboxNetworkRule]]`` + — callers wrap it in their endpoint-specific rules attrs class. + """ + rules = network.get("rules") or {} + allow_out = _resolve_network_selector(network.get("allow_out"), rules) + deny_out = _resolve_network_selector(network.get("deny_out"), rules) + + body: Dict[str, Any] = {} + if allow_out is not None: + body["allow_out"] = allow_out + if deny_out is not None: + body["deny_out"] = deny_out + if "rules" in network and network["rules"] is not None: + body["rules"] = _build_client_rules(network["rules"]).additional_properties + + return body + + +def build_network_config( + network: Optional[SandboxNetworkOpts], +) -> Optional[Dict[str, Any]]: + """Resolve a :class:`SandboxNetworkOpts` into the dict the API expects.""" + if network is None: + return None + + body = _build_network_egress(network) + if "rules" in body: + client_rules = SandboxNetworkConfigRules() + client_rules.additional_properties = body["rules"] + body["rules"] = client_rules + if "allow_public_traffic" in network: + body["allow_public_traffic"] = network["allow_public_traffic"] + if "mask_request_host" in network: + body["mask_request_host"] = network["mask_request_host"] + + return body + + +def build_network_update_body( + network: SandboxNetworkUpdate, +) -> SandboxNetworkUpdateConfig: + """Resolve a :class:`SandboxNetworkUpdate` into the API client body.""" + egress = _build_network_egress(network) + + body = SandboxNetworkUpdateConfig() + if "allow_out" in egress: + body.allow_out = egress["allow_out"] + if "deny_out" in egress: + body.deny_out = egress["deny_out"] + if "rules" in egress: + rules = SandboxNetworkUpdateConfigRules() + rules.additional_properties = egress["rules"] + body.rules = rules + if "allow_internet_access" in network: + body.allow_internet_access = network["allow_internet_access"] + + return body + + +def from_client_network_config( + network: Union[Unset, ClientSandboxNetworkConfig], +) -> Optional[SandboxNetworkInfo]: + if isinstance(network, Unset): + return None + + result: SandboxNetworkInfo = {} + + if not isinstance(network.allow_out, Unset): + result["allow_out"] = list(network.allow_out) + if not isinstance(network.deny_out, Unset): + result["deny_out"] = list(network.deny_out) + if not isinstance(network.rules, Unset): + result["rules"] = cast( + Dict[str, List[SandboxNetworkRuleInfo]], network.rules.to_dict() + ) + if not isinstance(network.allow_public_traffic, Unset): + result["allow_public_traffic"] = network.allow_public_traffic + if not isinstance(network.mask_request_host, Unset): + result["mask_request_host"] = network.mask_request_host + + return result + + +def from_client_lifecycle( + lifecycle: Union[Unset, ClientSandboxLifecycle], +) -> Optional[SandboxInfoLifecycle]: + if isinstance(lifecycle, Unset): + return None + + result: SandboxInfoLifecycle = { + "on_timeout": cast(Literal["pause", "kill"], lifecycle.on_timeout), + "auto_resume": lifecycle.auto_resume, + } + + return result + + +@dataclass +class SandboxInfo: + """Information about a sandbox.""" + + sandbox_id: str + """Sandbox ID.""" + sandbox_domain: Optional[str] + """Domain where the sandbox is hosted.""" + template_id: str + """Template ID.""" + name: Optional[str] + """Template name.""" + metadata: Dict[str, str] + """Saved sandbox metadata.""" + started_at: datetime + """Sandbox start time.""" + end_at: datetime + """Sandbox expiration date.""" + state: SandboxState + """Sandbox state.""" + cpu_count: int + """Sandbox CPU count.""" + memory_mb: int + """Sandbox Memory size in MiB.""" + envd_version: str + """Envd version.""" + allow_internet_access: Optional[bool] = None + """Whether internet access was explicitly enabled or disabled for the sandbox.""" + network: Optional[SandboxNetworkInfo] = None + """Sandbox network configuration.""" + lifecycle: Optional[SandboxInfoLifecycle] = None + """Sandbox lifecycle configuration.""" + volume_mounts: List[Dict[str, str]] = field(default_factory=list) + """Volume mounts for the sandbox.""" + + @classmethod + def _from_sandbox_data( + cls, + sandbox: Union[ListedSandbox, SandboxDetail], + sandbox_domain: Optional[str] = None, + allow_internet_access: Optional[bool] = None, + network: Optional[SandboxNetworkInfo] = None, + lifecycle: Optional[SandboxInfoLifecycle] = None, + ): + return cls( + sandbox_domain=sandbox_domain, + sandbox_id=sandbox.sandbox_id, + template_id=sandbox.template_id, + name=(sandbox.alias if isinstance(sandbox.alias, str) else None), + metadata=cast( + Dict[str, str], + sandbox.metadata if isinstance(sandbox.metadata, dict) else {}, + ), + started_at=sandbox.started_at, + end_at=sandbox.end_at, + state=sandbox.state, + cpu_count=sandbox.cpu_count, + memory_mb=sandbox.memory_mb, + envd_version=sandbox.envd_version, + volume_mounts=[ + {"name": vm.name, "path": vm.path} for vm in sandbox.volume_mounts + ] + if not isinstance(sandbox.volume_mounts, Unset) + else [], + allow_internet_access=allow_internet_access, + network=network, + lifecycle=lifecycle, + ) + + @classmethod + def _from_listed_sandbox(cls, listed_sandbox: ListedSandbox): + return cls._from_sandbox_data(listed_sandbox) + + @classmethod + def _from_sandbox_detail(cls, sandbox_detail: SandboxDetail): + return cls._from_sandbox_data( + sandbox_detail, + sandbox_domain=( + sandbox_detail.domain + if isinstance(sandbox_detail.domain, str) + else None + ), + allow_internet_access=( + sandbox_detail.allow_internet_access + if isinstance(sandbox_detail.allow_internet_access, bool) + else None + ), + network=from_client_network_config(sandbox_detail.network), + lifecycle=from_client_lifecycle(sandbox_detail.lifecycle), + ) + + +@dataclass +class SandboxQuery: + """Query parameters for listing sandboxes.""" + + metadata: Optional[dict[str, str]] = None + """Filter sandboxes by metadata.""" + + state: Optional[list[SandboxState]] = None + """Filter sandboxes by state.""" + + +@dataclass +class SandboxMetrics: + """Sandbox metrics.""" + + cpu_count: int + """Number of CPUs.""" + cpu_used_pct: float + """CPU usage percentage.""" + disk_total: int + """Total disk space in bytes.""" + disk_used: int + """Disk used in bytes.""" + mem_total: int + """Total memory in bytes.""" + mem_used: int + """Memory used in bytes.""" + mem_cache: int + """Cached memory (page cache) in bytes.""" + timestamp: datetime + """Timestamp of the metric entry.""" + + +@dataclass +class SnapshotInfo: + """Information about a snapshot.""" + + snapshot_id: str + """Snapshot identifier — template ID with tag, or namespaced name with tag (e.g. my-snapshot:latest). Can be used with Sandbox.create() to create a new sandbox from this snapshot.""" + names: List[str] = field(default_factory=list) + """Full names of the snapshot template including team namespace and tag (e.g. team-slug/my-snapshot:v2).""" + + +class SnapshotPaginatorBase(PaginatorBase[SnapshotInfo, ApiParams]): + def __init__( + self, + sandbox_id: Optional[str] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ): + super().__init__(limit=limit, next_token=next_token, **opts) + self.sandbox_id = sandbox_id + + +class SandboxPaginatorBase(PaginatorBase[SandboxInfo, ApiParams]): + def __init__( + self, + query: Optional[SandboxQuery] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ): + super().__init__(limit=limit, next_token=next_token, **opts) + self.query = query diff --git a/packages/python-sdk/e2b/sandbox/signature.py b/packages/python-sdk/e2b/sandbox/signature.py new file mode 100644 index 0000000..34f1546 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/signature.py @@ -0,0 +1,47 @@ +import base64 +import hashlib +import time + +from typing import Optional, TypedDict, Literal + +Operation = Literal["read", "write"] + + +class Signature(TypedDict): + signature: str + expiration: Optional[int] # Unix timestamp or None + + +def get_signature( + path: str, + operation: Operation, + user: Optional[str], + envd_access_token: Optional[str], + expiration_in_seconds: Optional[int] = None, +) -> Signature: + """ + Generate a v1 signature for sandbox file URLs. + """ + if not envd_access_token: + raise ValueError("Access token is not set and signature cannot be generated!") + + expiration = ( + int(time.time()) + expiration_in_seconds + if expiration_in_seconds is not None + else None + ) + + # if user is None, set it to empty string to handle default user + if user is None: + user = "" + + raw = ( + f"{path}:{operation}:{user}:{envd_access_token}" + if expiration is None + else f"{path}:{operation}:{user}:{envd_access_token}:{expiration}" + ) + + digest = hashlib.sha256(raw.encode("utf-8")).digest() + encoded = base64.b64encode(digest).rstrip(b"=").decode("ascii") + + return {"signature": f"v1_{encoded}", "expiration": expiration} diff --git a/packages/python-sdk/e2b/sandbox/utils.py b/packages/python-sdk/e2b/sandbox/utils.py new file mode 100644 index 0000000..e147409 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/utils.py @@ -0,0 +1,34 @@ +from typing import TypeVar, Any, Generic, cast, Optional, Type +import functools + +T = TypeVar("T") + + +class class_method_variant(Generic[T]): + def __init__(self, class_method_name): + self.class_method_name = class_method_name + + method: Any + + def __call__(self, method: T) -> T: + self.method = method + return cast(T, self) + + def __get__(self, obj, objtype: Optional[Type[Any]] = None): + @functools.wraps(self.method) + def _wrapper(*args, **kwargs): + if obj is not None: + # Method was called as an instance method, e.g. + # instance.method(...) + return self.method(obj, *args, **kwargs) + elif len(args) > 0 and objtype is not None and isinstance(args[0], objtype): + # Method was called as a class method with the instance as the + # first argument, e.g. Class.method(instance, ...) which in + # Python is the same thing as calling an instance method + return self.method(args[0], *args[1:], **kwargs) + else: + # Method was called as a class method, e.g. Class.method(...) + class_method = getattr(objtype, self.class_method_name) + return class_method(*args, **kwargs) + + return _wrapper diff --git a/packages/python-sdk/e2b/sandbox_async/commands/command.py b/packages/python-sdk/e2b/sandbox_async/commands/command.py new file mode 100644 index 0000000..93d6c3b --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_async/commands/command.py @@ -0,0 +1,396 @@ +from typing import Dict, List, Literal, Optional, Union, overload + +import e2b_connect +import httpcore +import httpx +from packaging.version import Version +from e2b.connection_config import ( + ConnectionConfig, + Username, + KEEPALIVE_PING_HEADER, + KEEPALIVE_PING_INTERVAL_SEC, +) +from e2b.envd.process import process_connect, process_pb2 +from e2b.envd.api import acheck_sandbox_health +from e2b.envd.rpc import authentication_header, ahandle_rpc_exception_with_health +from e2b.envd.versions import ENVD_COMMANDS_STDIN, ENVD_ENVD_CLOSE +from e2b.exceptions import SandboxException +from e2b.sandbox.commands.main import ProcessInfo +from e2b.sandbox.commands.command_handle import CommandResult +from e2b.sandbox_async.commands.command_handle import AsyncCommandHandle, Stderr, Stdout +from e2b.sandbox_async.utils import OutputHandler + + +class Commands: + """ + Module for executing commands in the sandbox. + """ + + def __init__( + self, + envd_api_url: str, + connection_config: ConnectionConfig, + pool: httpcore.AsyncConnectionPool, + envd_version: Version, + envd_api: httpx.AsyncClient, + ) -> None: + self._connection_config = connection_config + self._envd_version = envd_version + self._check_health = lambda: acheck_sandbox_health(envd_api) + self._rpc = process_connect.ProcessClient( + envd_api_url, + # TODO: Fix and enable compression again — the headers compression is not solved for streaming. + # compressor=e2b_connect.GzipCompressor, + async_pool=pool, + json=True, + headers=connection_config.sandbox_headers, + logger=connection_config.logger, + ) + + async def list( + self, + request_timeout: Optional[float] = None, + ) -> List[ProcessInfo]: + """ + Lists all running commands and PTY sessions. + + :param request_timeout: Timeout for the request in **seconds** + + :return: List of running commands and PTY sessions + """ + try: + res = await self._rpc.alist( + process_pb2.ListRequest(), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + return [ + ProcessInfo( + pid=p.pid, + tag=p.tag if p.HasField("tag") else None, + cmd=p.config.cmd, + args=list(p.config.args), + envs=dict(p.config.envs), + cwd=p.config.cwd if p.config.HasField("cwd") else None, + ) + for p in res.processes + ] + except Exception as e: + raise await ahandle_rpc_exception_with_health(e, self._check_health) + + async def kill( + self, + pid: int, + request_timeout: Optional[float] = None, + ) -> bool: + """ + Kill a running command specified by its process ID. + It uses `SIGKILL` signal to kill the command. + + :param pid: Process ID of the command. You can get the list of processes using `sandbox.commands.list()` + :param request_timeout: Timeout for the request in **seconds** + + :return: `True` if the command was killed, `False` if the command was not found + """ + try: + await self._rpc.asend_signal( + process_pb2.SendSignalRequest( + process=process_pb2.ProcessSelector(pid=pid), + signal=process_pb2.Signal.SIGNAL_SIGKILL, + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + return True + except Exception as e: + if isinstance(e, e2b_connect.ConnectException): + if e.status == e2b_connect.Code.not_found: + return False + raise await ahandle_rpc_exception_with_health(e, self._check_health) + + async def send_stdin( + self, + pid: int, + data: Union[str, bytes], + request_timeout: Optional[float] = None, + ) -> None: + """ + Send data to command stdin. + + :param pid Process ID of the command. You can get the list of processes using `sandbox.commands.list()`. + :param data: Data to send to the command + :param request_timeout: Timeout for the request in **seconds** + """ + try: + await self._rpc.asend_input( + process_pb2.SendInputRequest( + process=process_pb2.ProcessSelector(pid=pid), + input=process_pb2.ProcessInput( + stdin=data.encode() if isinstance(data, str) else data, + ), + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + except Exception as e: + raise await ahandle_rpc_exception_with_health(e, self._check_health) + + async def close_stdin( + self, + pid: int, + request_timeout: Optional[float] = None, + ) -> None: + """ + Close the command stdin. + + This signals EOF to the command. The command must have been started with `stdin=True`. + + :param pid Process ID of the command. You can get the list of processes using `sandbox.commands.list()`. + :param request_timeout: Timeout for the request in **seconds** + """ + if self._envd_version < ENVD_ENVD_CLOSE: + raise SandboxException( + f"Sandbox envd version {self._envd_version} doesn't support closing stdin. " + f"Please rebuild your template to pick up the latest sandbox version." + ) + + try: + await self._rpc.aclose_stdin( + process_pb2.CloseStdinRequest( + process=process_pb2.ProcessSelector(pid=pid), + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + except Exception as e: + raise await ahandle_rpc_exception_with_health(e, self._check_health) + + @overload + async def run( + self, + cmd: str, + background: Union[Literal[False], None] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[Username] = None, + cwd: Optional[str] = None, + on_stdout: Optional[OutputHandler[Stdout]] = None, + on_stderr: Optional[OutputHandler[Stderr]] = None, + stdin: Optional[bool] = None, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ) -> CommandResult: + """ + Start a new command and wait until it finishes executing. + + :param cmd: Command to execute + :param background: **`False` if the command should be executed in the foreground**, `True` if the command should be executed in the background + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param on_stdout: Callback for command stdout output + :param on_stderr: Callback for command stderr output + :param stdin: If `True`, the command will have a stdin stream that you can send data to using `sandbox.commands.send_stdin()` + :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time + :param request_timeout: Timeout for the request in **seconds** + + :return: `CommandResult` result of the command execution + """ + ... + + @overload + async def run( + self, + cmd: str, + background: Literal[True], + envs: Optional[Dict[str, str]] = None, + user: Optional[Username] = None, + cwd: Optional[str] = None, + on_stdout: Optional[OutputHandler[Stdout]] = None, + on_stderr: Optional[OutputHandler[Stderr]] = None, + stdin: Optional[bool] = None, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ) -> AsyncCommandHandle: + """ + Start a new command and return a handle to interact with it. + + :param cmd: Command to execute + :param background: `False` if the command should be executed in the foreground, **`True` if the command should be executed in the background** + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param on_stdout: Callback for command stdout output + :param on_stderr: Callback for command stderr output + :param stdin: If `True`, the command will have a stdin stream that you can send data to using `sandbox.commands.send_stdin()` + :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time + :param request_timeout: Timeout for the request in **seconds** + + :return: `AsyncCommandHandle` handle to interact with the running command + """ + ... + + async def run( + self, + cmd: str, + background: Union[bool, None] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[Username] = None, + cwd: Optional[str] = None, + on_stdout: Optional[OutputHandler[Stdout]] = None, + on_stderr: Optional[OutputHandler[Stderr]] = None, + stdin: Optional[bool] = None, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ): + # Check version for stdin support + if stdin is False and self._envd_version < ENVD_COMMANDS_STDIN: + raise SandboxException( + f"Sandbox envd version {self._envd_version} can't specify stdin, it's always turned on. " + f"Please rebuild your template if you need this feature." + ) + + # Default to `False` + stdin = stdin or False + + proc = await self._start( + cmd, + envs, + user, + cwd, + stdin, + timeout, + request_timeout, + on_stdout=on_stdout, + on_stderr=on_stderr, + ) + + return proc if background else await proc.wait() + + async def _start( + self, + cmd: str, + envs: Optional[Dict[str, str]], + user: Optional[Username], + cwd: Optional[str], + stdin: bool, + timeout: Optional[float], + request_timeout: Optional[float], + on_stdout: Optional[OutputHandler[Stdout]], + on_stderr: Optional[OutputHandler[Stderr]], + ) -> AsyncCommandHandle: + events = self._rpc.astart( + process_pb2.StartRequest( + process=process_pb2.ProcessConfig( + cmd="/bin/bash", + envs=envs, + args=["-l", "-c", cmd], + cwd=cwd, + ), + stdin=stdin, + ), + headers={ + **authentication_header(self._envd_version, user), + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + timeout=timeout, + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + + try: + start_event = await events.__anext__() + + if not start_event.HasField("event"): + raise SandboxException( + f"Failed to start process: expected start event, got {start_event}" + ) + + pid = start_event.event.start.pid + return AsyncCommandHandle( + pid=pid, + handle_kill=lambda: self.kill(pid), + events=events, + on_stdout=on_stdout, + on_stderr=on_stderr, + handle_send_stdin=lambda data, request_timeout=None: self.send_stdin( + pid, data, request_timeout + ), + handle_close_stdin=lambda request_timeout=None: self.close_stdin( + pid, request_timeout + ), + check_health=self._check_health, + ) + except Exception as e: + try: + await events.aclose() + except Exception: + pass + raise await ahandle_rpc_exception_with_health(e, self._check_health) + + async def connect( + self, + pid: int, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + on_stdout: Optional[OutputHandler[Stdout]] = None, + on_stderr: Optional[OutputHandler[Stderr]] = None, + ) -> AsyncCommandHandle: + """ + Connects to a running command. + You can use `AsyncCommandHandle.wait()` to wait for the command to finish and get execution results. + + :param pid: Process ID of the command to connect to. You can get the list of processes using `sandbox.commands.list()` + :param request_timeout: Request timeout in **seconds** + :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time + :param on_stdout: Callback for command stdout output + :param on_stderr: Callback for command stderr output + + :return: `AsyncCommandHandle` handle to interact with the running command + """ + events = self._rpc.aconnect( + process_pb2.ConnectRequest( + process=process_pb2.ProcessSelector(pid=pid), + ), + headers={ + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + timeout=timeout, + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + + try: + start_event = await events.__anext__() + + if not start_event.HasField("event"): + raise SandboxException( + f"Failed to connect to process: expected start event, got {start_event}" + ) + + pid = start_event.event.start.pid + return AsyncCommandHandle( + pid=pid, + handle_kill=lambda: self.kill(pid), + events=events, + on_stdout=on_stdout, + on_stderr=on_stderr, + handle_send_stdin=lambda data, request_timeout=None: self.send_stdin( + pid, data, request_timeout + ), + handle_close_stdin=lambda request_timeout=None: self.close_stdin( + pid, request_timeout + ), + check_health=self._check_health, + ) + except Exception as e: + try: + await events.aclose() + except Exception: + pass + raise await ahandle_rpc_exception_with_health(e, self._check_health) diff --git a/packages/python-sdk/e2b/sandbox_async/commands/command_handle.py b/packages/python-sdk/e2b/sandbox_async/commands/command_handle.py new file mode 100644 index 0000000..a8436be --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_async/commands/command_handle.py @@ -0,0 +1,298 @@ +import asyncio +import codecs +import inspect +from typing import ( + Optional, + Callable, + Any, + AsyncGenerator, + List, + Awaitable, + Union, + Tuple, + Coroutine, +) + +from e2b.envd.rpc import ahandle_rpc_exception_with_health +from e2b.envd.process import process_pb2 +from e2b.exceptions import SandboxException +from e2b.sandbox.commands.command_handle import ( + CommandExitException, + CommandResult, + Stderr, + Stdout, + PtyOutput, +) +from e2b.sandbox_async.utils import OutputHandler + + +class AsyncCommandHandle: + """ + Command execution handle. + + It provides methods for waiting for the command to finish, retrieving stdout/stderr, and killing the command. + """ + + @property + def pid(self): + """ + Command process ID. + """ + return self._pid + + @property + def stdout(self): + """ + Command stdout output. + """ + return "".join(self._stdout_chunks) + + @property + def stderr(self): + """ + Command stderr output. + """ + return "".join(self._stderr_chunks) + + @property + def error(self): + """ + Command execution error message. + """ + if self._result is None: + return None + return self._result.error + + @property + def exit_code(self): + """ + Command execution exit code. + + `0` if the command finished successfully. + + It is `None` if the command is still running. + """ + if self._result is None: + return None + return self._result.exit_code + + def __init__( + self, + pid: int, + handle_kill: Callable[[], Coroutine[Any, Any, bool]], + events: AsyncGenerator[ + Union[process_pb2.StartResponse, process_pb2.ConnectResponse], Any + ], + on_stdout: Optional[OutputHandler[Stdout]] = None, + on_stderr: Optional[OutputHandler[Stderr]] = None, + on_pty: Optional[OutputHandler[PtyOutput]] = None, + handle_send_stdin: Optional[ + Callable[[Union[str, bytes], Optional[float]], Coroutine[Any, Any, None]] + ] = None, + handle_close_stdin: Optional[ + Callable[[Optional[float]], Coroutine[Any, Any, None]] + ] = None, + check_health: Optional[Callable[[], Awaitable[Optional[bool]]]] = None, + ): + self._pid = pid + self._handle_kill = handle_kill + self._handle_send_stdin = handle_send_stdin + self._handle_close_stdin = handle_close_stdin + self._check_health = check_health + self._events = events + + self._stdout_chunks: List[str] = [] + self._stderr_chunks: List[str] = [] + + self._stdout_decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + self._stderr_decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + + self._on_stdout = on_stdout + self._on_stderr = on_stderr + self._on_pty = on_pty + + self._result: Optional[CommandResult] = None + self._iteration_exception: Optional[Exception] = None + + self._wait = asyncio.create_task(self._handle_events()) + + def _flush_decoders( + self, + ) -> List[Union[Tuple[Stdout, None, None], Tuple[None, Stderr, None]]]: + """ + Flush any bytes still buffered in the stream decoders. + + Incomplete trailing UTF-8 sequences are emitted as replacement + characters, matching the per-chunk decoding behavior. + """ + events: List[Union[Tuple[Stdout, None, None], Tuple[None, Stderr, None]]] = [] + out = self._stdout_decoder.decode(b"", final=True) + if out: + self._stdout_chunks.append(out) + events.append((out, None, None)) + err = self._stderr_decoder.decode(b"", final=True) + if err: + self._stderr_chunks.append(err) + events.append((None, err, None)) + return events + + async def _iterate_events( + self, + ) -> AsyncGenerator[ + Union[ + Tuple[Stdout, None, None], + Tuple[None, Stderr, None], + Tuple[None, None, PtyOutput], + ], + None, + ]: + try: + async for event in self._events: + if event.event.HasField("data"): + if event.event.data.stdout: + out = self._stdout_decoder.decode(event.event.data.stdout) + if out: + self._stdout_chunks.append(out) + yield out, None, None + if event.event.data.stderr: + out = self._stderr_decoder.decode(event.event.data.stderr) + if out: + self._stderr_chunks.append(out) + yield None, out, None + if event.event.data.pty: + yield None, None, event.event.data.pty + if event.event.HasField("end"): + # Flush trailing decoder bytes into the accumulators and + # record the result before yielding the flushed chunks, so a + # consumer that stops iterating on the first flushed chunk + # still observes the exit code. + flushed = list(self._flush_decoders()) + self._result = CommandResult( + stdout="".join(self._stdout_chunks), + stderr="".join(self._stderr_chunks), + exit_code=event.event.end.exit_code, + error=event.event.end.error, + ) + for f in flushed: + yield f + except Exception: + # The stream raised before an end event (e.g. disconnect or RPC + # failure). Flush any bytes still buffered in the decoders so + # incomplete trailing sequences surface as replacement characters + # instead of being silently dropped, then re-raise so the error is + # still surfaced by the consumer. + for flushed in self._flush_decoders(): + yield flushed + raise + + # If the stream closed without an end event (e.g. disconnect or a + # dropped connection), flush any bytes still buffered in the decoders + # so incomplete trailing sequences surface as replacement characters + # instead of being silently dropped. + if self._result is None: + for flushed in self._flush_decoders(): + yield flushed + + async def disconnect(self) -> None: + """ + Disconnects from the command. + + The command is not killed, but SDK stops receiving events from the command. + You can reconnect to the command using `sandbox.commands.connect` method. + """ + self._wait.cancel() + await asyncio.wait([self._wait]) + try: + await self._events.aclose() + except Exception: + pass + + async def _handle_events(self): + try: + async for stdout, stderr, pty in self._iterate_events(): + if stdout is not None and self._on_stdout: + cb = self._on_stdout(stdout) + if inspect.isawaitable(cb): + await cb + elif stderr is not None and self._on_stderr: + cb = self._on_stderr(stderr) + if inspect.isawaitable(cb): + await cb + elif pty is not None and self._on_pty: + cb = self._on_pty(pty) + if inspect.isawaitable(cb): + await cb + except StopAsyncIteration: + pass + except Exception as e: + self._iteration_exception = await ahandle_rpc_exception_with_health( + e, self._check_health + ) + + async def wait(self) -> CommandResult: + """ + Wait for the command to finish and return the result. + If the command exits with a non-zero exit code, it throws a `CommandExitException`. + + :return: `CommandResult` result of command execution + """ + await self._wait + if self._iteration_exception: + raise self._iteration_exception + + if self._result is None: + raise Exception("Command ended without an end event") + + if self._result.exit_code != 0: + raise CommandExitException( + stdout="".join(self._stdout_chunks), + stderr="".join(self._stderr_chunks), + exit_code=self._result.exit_code, + error=self._result.error, + ) + + return self._result + + async def kill(self) -> bool: + """ + Kills the command. + + It uses `SIGKILL` signal to kill the command + + :return: `True` if the command was killed successfully, `False` if the command was not found + """ + result = await self._handle_kill() + return result + + async def send_stdin( + self, + data: Union[str, bytes], + request_timeout: Optional[float] = None, + ) -> None: + """ + Send data to the command stdin. + + The command must have been started with `stdin=True`. + + :param data: Data to send to the command + :param request_timeout: Timeout for the request in **seconds** + """ + if self._handle_send_stdin is None: + raise SandboxException( + "Sending stdin is not supported for this command handle." + ) + await self._handle_send_stdin(data, request_timeout) + + async def close_stdin(self, request_timeout: Optional[float] = None) -> None: + """ + Close the command stdin. + + This signals EOF to the command. The command must have been started with `stdin=True`. + + :param request_timeout: Timeout for the request in **seconds** + """ + if self._handle_close_stdin is None: + raise SandboxException( + "Closing stdin is not supported for this command handle." + ) + await self._handle_close_stdin(request_timeout) diff --git a/packages/python-sdk/e2b/sandbox_async/commands/pty.py b/packages/python-sdk/e2b/sandbox_async/commands/pty.py new file mode 100644 index 0000000..db793f2 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_async/commands/pty.py @@ -0,0 +1,257 @@ +from typing import Dict, Optional + +import e2b_connect +import httpcore +import httpx + +from packaging.version import Version +from e2b.envd.process import process_connect, process_pb2 +from e2b.connection_config import ( + Username, + ConnectionConfig, + KEEPALIVE_PING_HEADER, + KEEPALIVE_PING_INTERVAL_SEC, +) +from e2b.exceptions import SandboxException +from e2b.envd.api import acheck_sandbox_health +from e2b.envd.rpc import authentication_header, ahandle_rpc_exception_with_health +from e2b.sandbox.commands.command_handle import PtySize +from e2b.sandbox_async.commands.command_handle import ( + AsyncCommandHandle, + OutputHandler, + PtyOutput, +) + + +class Pty: + """ + Module for interacting with PTYs (pseudo-terminals) in the sandbox. + """ + + def __init__( + self, + envd_api_url: str, + connection_config: ConnectionConfig, + pool: httpcore.AsyncConnectionPool, + envd_version: Version, + envd_api: httpx.AsyncClient, + ) -> None: + self._connection_config = connection_config + self._envd_version = envd_version + self._check_health = lambda: acheck_sandbox_health(envd_api) + self._rpc = process_connect.ProcessClient( + envd_api_url, + # TODO: Fix and enable compression again — the headers compression is not solved for streaming. + # compressor=e2b_connect.GzipCompressor, + async_pool=pool, + json=True, + headers=connection_config.sandbox_headers, + logger=connection_config.logger, + ) + + async def kill( + self, + pid: int, + request_timeout: Optional[float] = None, + ) -> bool: + """ + Kill PTY. + + :param pid: Process ID of the PTY + :param request_timeout: Timeout for the request in **seconds** + + :return: `true` if the PTY was killed, `false` if the PTY was not found + """ + try: + await self._rpc.asend_signal( + process_pb2.SendSignalRequest( + process=process_pb2.ProcessSelector(pid=pid), + signal=process_pb2.Signal.SIGNAL_SIGKILL, + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + return True + except Exception as e: + if isinstance(e, e2b_connect.ConnectException): + if e.status == e2b_connect.Code.not_found: + return False + raise await ahandle_rpc_exception_with_health(e, self._check_health) + + async def send_stdin( + self, + pid: int, + data: bytes, + request_timeout: Optional[float] = None, + ) -> None: + """ + Send input to a PTY. + + :param pid: Process ID of the PTY + :param data: Input data to send + :param request_timeout: Timeout for the request in **seconds** + """ + try: + await self._rpc.asend_input( + process_pb2.SendInputRequest( + process=process_pb2.ProcessSelector(pid=pid), + input=process_pb2.ProcessInput( + pty=data, + ), + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + except Exception as e: + raise await ahandle_rpc_exception_with_health(e, self._check_health) + + async def create( + self, + size: PtySize, + on_data: OutputHandler[PtyOutput], + user: Optional[Username] = None, + cwd: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ) -> AsyncCommandHandle: + """ + Start a new PTY (pseudo-terminal). + + :param size: Size of the PTY + :param on_data: Callback to handle PTY data + :param user: User to use for the PTY + :param cwd: Working directory for the PTY + :param envs: Environment variables for the PTY + :param timeout: Timeout for the PTY in **seconds** + :param request_timeout: Timeout for the request in **seconds** + + :return: Handle to interact with the PTY + """ + envs = dict(envs) if envs else {} + envs.setdefault("TERM", "xterm-256color") + envs.setdefault("LANG", "C.UTF-8") + envs.setdefault("LC_ALL", "C.UTF-8") + events = self._rpc.astart( + process_pb2.StartRequest( + process=process_pb2.ProcessConfig( + cmd="/bin/bash", + envs=envs, + args=["-i", "-l"], + cwd=cwd, + ), + pty=process_pb2.PTY( + size=process_pb2.PTY.Size(rows=size.rows, cols=size.cols) + ), + ), + headers={ + **authentication_header(self._envd_version, user), + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + timeout=timeout, + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + + try: + start_event = await events.__anext__() + + if not start_event.HasField("event"): + raise SandboxException( + f"Failed to start process: expected start event, got {start_event}" + ) + + return AsyncCommandHandle( + pid=start_event.event.start.pid, + handle_kill=lambda: self.kill(start_event.event.start.pid), + events=events, + on_pty=on_data, + check_health=self._check_health, + ) + except Exception as e: + try: + await events.aclose() + except Exception: + pass + raise await ahandle_rpc_exception_with_health(e, self._check_health) + + async def connect( + self, + pid: int, + on_data: OutputHandler[PtyOutput], + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ) -> AsyncCommandHandle: + """ + Connect to a running PTY. + + :param pid: Process ID of the PTY to connect to. You can get the list of running PTYs using `sandbox.pty.list()`. + :param on_data: Callback to handle PTY data + :param timeout: Timeout for the PTY connection in **seconds**. Using `0` will not limit the connection time + :param request_timeout: Timeout for the request in **seconds** + + :return: Handle to interact with the PTY + """ + events = self._rpc.aconnect( + process_pb2.ConnectRequest( + process=process_pb2.ProcessSelector(pid=pid), + ), + timeout=timeout, + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers={ + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + ) + + try: + start_event = await events.__anext__() + + if not start_event.HasField("event"): + raise SandboxException( + f"Failed to connect to process: expected start event, got {start_event}" + ) + + return AsyncCommandHandle( + pid=start_event.event.start.pid, + handle_kill=lambda: self.kill(start_event.event.start.pid), + events=events, + on_pty=on_data, + check_health=self._check_health, + ) + except Exception as e: + try: + await events.aclose() + except Exception: + pass + raise await ahandle_rpc_exception_with_health(e, self._check_health) + + async def resize( + self, + pid: int, + size: PtySize, + request_timeout: Optional[float] = None, + ) -> None: + """ + Resize PTY. + Call this when the terminal window is resized and the number of columns and rows has changed. + + :param pid: Process ID of the PTY + :param size: New size of the PTY + :param request_timeout: Timeout for the request in **seconds** + """ + await self._rpc.aupdate( + process_pb2.UpdateRequest( + process=process_pb2.ProcessSelector(pid=pid), + pty=process_pb2.PTY( + size=process_pb2.PTY.Size(rows=size.rows, cols=size.cols), + ), + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py new file mode 100644 index 0000000..e40be70 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py @@ -0,0 +1,720 @@ +import asyncio +from typing import IO, Dict, List, Literal, Optional, Union, overload + + +import httpcore +import httpx +from packaging.version import Version + +import e2b_connect as connect +from e2b.connection_config import ( + KEEPALIVE_PING_HEADER, + KEEPALIVE_PING_INTERVAL_SEC, + ConnectionConfig, + Username, + default_username, +) +from e2b.envd.api import ( + ENVD_API_FILES_ROUTE, + acheck_sandbox_health, + ahandle_envd_api_exception, + ahandle_envd_api_transport_exception_with_health, +) +from e2b.envd.filesystem import filesystem_connect, filesystem_pb2 +from e2b.envd.rpc import authentication_header, ahandle_rpc_exception_with_health +from e2b.envd.versions import ( + ENVD_DEFAULT_USER, + ENVD_FILE_METADATA, + ENVD_OCTET_STREAM_UPLOAD, + ENVD_VERSION_FS_EVENT_ENTRY_INFO, + ENVD_VERSION_RECURSIVE_WATCH, + ENVD_VERSION_WATCH_NETWORK_MOUNTS, +) +from e2b.exceptions import ( + FileNotFoundException, + InvalidArgumentException, + SandboxException, + TemplateException, +) +from e2b.sandbox.filesystem.filesystem import ( + AsyncFileStreamReader, + EntryInfo, + WriteEntry, + WriteInfo, + _to_httpx_file, + map_entry_info, + map_file_type, + metadata_to_headers, + to_upload_body_async, + validate_metadata, +) +from e2b.sandbox.filesystem.watch_handle import FilesystemEvent +from e2b.sandbox_async.filesystem.watch_handle import AsyncWatchHandle +from e2b.sandbox_async.utils import OutputHandler +from e2b_connect.client import Code + +_FILESYSTEM_RPC_ERROR_MAP = { + Code.not_found: FileNotFoundException, +} + +_FILESYSTEM_HTTP_ERROR_MAP = { + 404: FileNotFoundException, +} + + +async def _ahandle_filesystem_rpc_exception( + e: Exception, envd_api: httpx.AsyncClient +) -> Exception: + return await ahandle_rpc_exception_with_health( + e, lambda: acheck_sandbox_health(envd_api), _FILESYSTEM_RPC_ERROR_MAP + ) + + +async def _ahandle_filesystem_envd_api_exception(r): + return await ahandle_envd_api_exception(r, _FILESYSTEM_HTTP_ERROR_MAP) + + +class Filesystem: + """ + Module for interacting with the filesystem in the sandbox. + """ + + def __init__( + self, + envd_api_url: str, + envd_version: Version, + connection_config: ConnectionConfig, + pool: httpcore.AsyncConnectionPool, + envd_api: httpx.AsyncClient, + ) -> None: + self._envd_api_url = envd_api_url + self._envd_version = envd_version + self._connection_config = connection_config + self._pool = pool + self._envd_api = envd_api + + self._rpc = filesystem_connect.FilesystemClient( + envd_api_url, + # TODO: Fix and enable compression again — the headers compression is not solved for streaming. + # compressor=e2b_connect.GzipCompressor, + async_pool=pool, + json=True, + headers=connection_config.sandbox_headers, + logger=connection_config.logger, + ) + + @overload + async def read( + self, + path: str, + format: Literal["text"] = "text", + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + gzip: bool = False, + ) -> str: + """ + Read file content as a `str`. + + :param path: Path to the file + :param user: Run the operation as this user + :param format: Format of the file content—`text` by default + :param request_timeout: Timeout for the request in **seconds** + :param gzip: Use gzip compression for the request + + :return: File content as a `str` + """ + ... + + @overload + async def read( + self, + path: str, + format: Literal["bytes"], + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + gzip: bool = False, + ) -> bytearray: + """ + Read file content as a `bytearray`. + + :param path: Path to the file + :param user: Run the operation as this user + :param format: Format of the file content—`bytes` + :param request_timeout: Timeout for the request in **seconds** + :param gzip: Use gzip compression for the request + + :return: File content as a `bytearray` + """ + ... + + @overload + async def read( + self, + path: str, + format: Literal["stream"], + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + gzip: bool = False, + stream_idle_timeout: Optional[float] = None, + ) -> AsyncFileStreamReader: + """ + Read file content as an `AsyncFileStreamReader` (an `AsyncIterator[bytes]`). + + The request timeout bounds only the initial handshake—the returned + iterator is not killed by it while being consumed. A stalled stream is + reclaimed by `stream_idle_timeout` (raising `httpx.ReadTimeout`). The + reader releases its connection once fully consumed; if you don't read it + to the end, use it as an async context manager or call `aclose()` for + deterministic cleanup. There is no garbage-collection safety net—an + abandoned stream holds its connection until the idle timeout fires or + the client is closed. + + :param path: Path to the file + :param user: Run the operation as this user + :param format: Format of the file content—`stream` + :param request_timeout: Timeout for the request in **seconds** + :param gzip: Use gzip compression for the request + :param stream_idle_timeout: Idle timeout in **seconds** for the streamed + body—abort if no chunk arrives within this window. Resets on every + chunk, so it bounds a stalled stream without limiting total transfer + time. Defaults to the request timeout; pass `0` to disable. + + :return: File content as an `AsyncFileStreamReader` + """ + ... + + async def read( + self, + path: str, + format: Literal["text", "bytes", "stream"] = "text", + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + gzip: bool = False, + stream_idle_timeout: Optional[float] = None, + ): + username = user + if username is None and self._envd_version < ENVD_DEFAULT_USER: + username = default_username + + params = {"path": path} + if username: + params["username"] = username + + headers = {} + if gzip: + headers["Accept-Encoding"] = "gzip" + + timeout = self._connection_config.get_request_timeout(request_timeout) + + if format == "stream": + # Stream the response body instead of buffering it in memory. + request = self._envd_api.build_request( + "GET", + ENVD_API_FILES_ROUTE, + params=params, + headers=headers, + timeout=timeout, + ) + try: + r = await self._envd_api.send(request, stream=True) + except httpx.RemoteProtocolError as e: + raise await ahandle_envd_api_transport_exception_with_health( + e, self._envd_api + ) + + err = await _ahandle_filesystem_envd_api_exception(r) + if err: + await r.aclose() + raise err + + # The request timeout bounds only the initial handshake; httpx's + # per-chunk `read` timeout becomes the idle-read timeout for the body + # (defaults to the request timeout). The timeout dict is shared by + # reference with the transport and read again when iteration starts. + idle_timeout = ( + timeout if stream_idle_timeout is None else stream_idle_timeout + ) + request.extensions.get("timeout", {})["read"] = idle_timeout or None + + return AsyncFileStreamReader(r) + + try: + r = await self._envd_api.get( + ENVD_API_FILES_ROUTE, + params=params, + headers=headers, + timeout=timeout, + ) + except httpx.RemoteProtocolError as e: + raise await ahandle_envd_api_transport_exception_with_health( + e, self._envd_api + ) + + err = await _ahandle_filesystem_envd_api_exception(r) + if err: + raise err + + if format == "text": + return r.text + elif format == "bytes": + return bytearray(r.content) + + async def write( + self, + path: str, + data: Union[str, bytes, IO], + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + gzip: bool = False, + use_octet_stream: Optional[bool] = None, + metadata: Optional[Dict[str, str]] = None, + ) -> WriteInfo: + """ + Write content to a file on the path. + Writing to a file that doesn't exist creates the file. + Writing to a file that already exists overwrites the file. + Writing to a file at path that doesn't exist creates the necessary directories. + + :param path: Path to the file + :param data: Data to write to the file, can be a `str`, `bytes`, or `IO`. File-like objects are streamed in chunks instead of being buffered in memory. + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + :param gzip: Use gzip compression for the upload. Implies the `application/octet-stream` upload. Requires envd 0.5.7 or later — when not supported, the upload falls back to uncompressed `multipart/form-data`. + :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `None`, which uses octet-stream when `data` is a file-like object (so streamed uploads aren't buffered) and `multipart/form-data` otherwise. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. + :param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Keys are lowercased by the sandbox; invalid keys or values raise an `InvalidArgumentException`. Requires envd 0.6.2 or later. + + :return: Information about the written file + """ + result = await self.write_files( + [WriteEntry(path=path, data=data)], + user=user, + request_timeout=request_timeout, + gzip=gzip, + use_octet_stream=use_octet_stream, + metadata=metadata, + ) + + if len(result) != 1: + raise SandboxException("Received unexpected response from write operation") + + return result[0] + + async def write_files( + self, + files: List[WriteEntry], + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + gzip: bool = False, + use_octet_stream: Optional[bool] = None, + metadata: Optional[Dict[str, str]] = None, + ) -> List[WriteInfo]: + """ + Writes multiple files. + + Writes a list of files to the filesystem. + When writing to a file that doesn't exist, the file will get created. + When writing to a file that already exists, the file will get overwritten. + When writing to a file at path that doesn't exist, the necessary directories will be created. + + :param files: list of files to write as `WriteEntry` objects, each containing `path` and `data` + :param user: Run the operation as this user + :param request_timeout: Timeout for the request + :param gzip: Use gzip compression for the upload. Implies the `application/octet-stream` upload. Requires envd 0.5.7 or later — when not supported, the upload falls back to uncompressed `multipart/form-data`. + :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `None`, which uses octet-stream when any entry is a file-like object (so streamed uploads aren't buffered) and `multipart/form-data` otherwise. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. + :param metadata: User-defined metadata to persist on each uploaded file as extended attributes; the same map is applied to every file. Keys are lowercased by the sandbox; invalid keys or values raise an `InvalidArgumentException`. Requires envd 0.6.2 or later. + :return: Information about the written files + """ + username = user + if username is None and self._envd_version < ENVD_DEFAULT_USER: + username = default_username + + if len(files) == 0: + return [] + + validate_metadata(metadata) + + if metadata and self._envd_version < ENVD_FILE_METADATA: + raise TemplateException("File metadata requires envd 0.6.2 or later.") + + # A file-like entry is streamed; str/bytes are sent from memory. + has_streamable_data = any( + not isinstance(file["data"], (str, bytes)) for file in files + ) + + if use_octet_stream is None: + # Streaming an upload only happens on the octet-stream path; the + # multipart path buffers file-like data. Default to octet-stream + # when any entry is a file-like object so a streamed upload isn't + # silently buffered. + use_octet_stream = has_streamable_data + + supports_octet_stream = self._envd_version >= ENVD_OCTET_STREAM_UPLOAD + # Gzip compression only works with the octet-stream upload (the + # Content-Encoding header applies to the whole request body), so + # requesting gzip implies it when envd supports it. + use_octet_stream = (use_octet_stream or gzip) and supports_octet_stream + + # Each chunk send is bounded by the request timeout (httpx applies it + # per write); a stalled upload the per-write timeout can't observe is + # bounded server-side (envd's per-read idle timeout, envd >= 0.6.7). + upload_timeout = self._connection_config.get_request_timeout(request_timeout) + + # Metadata is sent as request-scoped X-Metadata-* headers, so the same + # metadata is applied to every file in a multi-file upload. + extra_headers = metadata_to_headers(metadata) + + results: List[WriteInfo] = [] + + if use_octet_stream: + + async def _upload_file(file): + file_path, file_data = file["path"], file["data"] + + params = {"path": file_path} + if username: + params["username"] = username + + headers = {"Content-Type": "application/octet-stream", **extra_headers} + if gzip: + headers["Content-Encoding"] = "gzip" + + try: + r = await self._envd_api.post( + ENVD_API_FILES_ROUTE, + content=to_upload_body_async(file_data, gzip), + headers=headers, + params=params, + timeout=upload_timeout, + ) + except httpx.RemoteProtocolError as e: + raise await ahandle_envd_api_transport_exception_with_health( + e, self._envd_api + ) + + err = await _ahandle_filesystem_envd_api_exception(r) + if err: + raise err + + write_result = r.json() + + if not isinstance(write_result, list) or len(write_result) == 0: + raise SandboxException( + "Expected to receive information about written file" + ) + + return [WriteInfo.from_dict(f) for f in write_result] + + upload_results = await asyncio.gather( + *[_upload_file(file) for file in files] + ) + for file_results in upload_results: + results.extend(file_results) + else: + params = {} + if username: + params["username"] = username + if len(files) == 1: + params["path"] = files[0]["path"] + + httpx_files = [_to_httpx_file(file["path"], file["data"]) for file in files] + + if len(httpx_files) == 0: + return [] + + try: + r = await self._envd_api.post( + ENVD_API_FILES_ROUTE, + files=httpx_files, + params=params, + headers=extra_headers, + timeout=upload_timeout, + ) + except httpx.RemoteProtocolError as e: + raise await ahandle_envd_api_transport_exception_with_health( + e, self._envd_api + ) + + err = await _ahandle_filesystem_envd_api_exception(r) + if err: + raise err + + write_result = r.json() + + if not isinstance(write_result, list) or len(write_result) == 0: + raise SandboxException( + "Expected to receive information about written file" + ) + + results.extend([WriteInfo.from_dict(f) for f in write_result]) + + return results + + async def list( + self, + path: str, + depth: Optional[int] = 1, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> List[EntryInfo]: + """ + List entries in a directory. + + :param path: Path to the directory + :param depth: Depth of the directory to list + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: List of entries in the directory + """ + if depth is not None and depth < 1: + raise InvalidArgumentException("depth should be at least 1") + + try: + res = await self._rpc.alist_dir( + filesystem_pb2.ListDirRequest(path=path, depth=depth), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + entries: List[EntryInfo] = [] + for entry in res.entries: + # Skip entries with an unknown file type. + if map_file_type(entry.type): + entries.append(map_entry_info(entry)) + + return entries + except Exception as e: + raise await _ahandle_filesystem_rpc_exception(e, self._envd_api) + + async def exists( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> bool: + """ + Check if a file or a directory exists. + + :param path: Path to a file or a directory + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: `True` if the file or directory exists, `False` otherwise + """ + try: + await self._rpc.astat( + filesystem_pb2.StatRequest(path=path), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + return True + + except Exception as e: + if isinstance(e, connect.ConnectException): + if e.status == connect.Code.not_found: + return False + raise await _ahandle_filesystem_rpc_exception(e, self._envd_api) + + async def get_info( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> EntryInfo: + """ + Get information about a file or directory. + + :param path: Path to a file or a directory + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: Information about the file or directory like name, type, and path + """ + try: + r = await self._rpc.astat( + filesystem_pb2.StatRequest(path=path), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + return map_entry_info(r.entry) + except Exception as e: + raise await _ahandle_filesystem_rpc_exception(e, self._envd_api) + + async def remove( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> None: + """ + Remove a file or a directory. + + :param path: Path to a file or a directory + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + """ + try: + await self._rpc.aremove( + filesystem_pb2.RemoveRequest(path=path), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + except Exception as e: + raise await _ahandle_filesystem_rpc_exception(e, self._envd_api) + + async def rename( + self, + old_path: str, + new_path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> EntryInfo: + """ + Rename a file or directory. + + :param old_path: Path to the file or directory to rename + :param new_path: New path to the file or directory + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: Information about the renamed file or directory + """ + try: + r = await self._rpc.amove( + filesystem_pb2.MoveRequest( + source=old_path, + destination=new_path, + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + return map_entry_info(r.entry) + except Exception as e: + raise await _ahandle_filesystem_rpc_exception(e, self._envd_api) + + async def make_dir( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> bool: + """ + Create a new directory and all directories along the way if needed on the specified path. + + :param path: Path to a new directory. For example '/dirA/dirB' when creating 'dirB'. + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: `True` if the directory was created, `False` if the directory already exists + """ + try: + await self._rpc.amake_dir( + filesystem_pb2.MakeDirRequest(path=path), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + return True + except Exception as e: + if isinstance(e, connect.ConnectException): + if e.status == connect.Code.already_exists: + return False + raise await _ahandle_filesystem_rpc_exception(e, self._envd_api) + + async def watch_dir( + self, + path: str, + on_event: OutputHandler[FilesystemEvent], + on_exit: Optional[OutputHandler[Optional[Exception]]] = None, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + timeout: Optional[float] = 60, + recursive: bool = False, + include_entry: bool = False, + allow_network_mounts: bool = False, + ) -> AsyncWatchHandle: + """ + Watch directory for filesystem events. + + :param path: Path to a directory to watch + :param on_event: Callback to call on each event in the directory + :param on_exit: Callback to call when the watching ends. It receives the error that ended the watch, or `None` on a clean end or when `stop()` is called + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + :param timeout: Timeout for the watch operation in **seconds**. Using `0` will not limit the watch time + :param recursive: Watch directory recursively + :param include_entry: Include the `EntryInfo` of the affected entry in each event, when available. Requires envd 0.6.3 or later + :param allow_network_mounts: Allow watching paths on network filesystem mounts (NFS, CIFS, SMB, FUSE), which are rejected by default. Events on network mounts may be unreliable or not delivered at all. Requires envd 0.6.4 or later + + :return: `AsyncWatchHandle` object for stopping watching directory + """ + if recursive and self._envd_version < ENVD_VERSION_RECURSIVE_WATCH: + raise TemplateException( + "You need to update the template to use recursive watching." + ) + + if include_entry and self._envd_version < ENVD_VERSION_FS_EVENT_ENTRY_INFO: + raise TemplateException( + "You need to update the template to include entry info in watch events." + ) + + if ( + allow_network_mounts + and self._envd_version < ENVD_VERSION_WATCH_NETWORK_MOUNTS + ): + raise TemplateException( + "You need to update the template to watch directories on network mounts." + ) + + events = self._rpc.awatch_dir( + filesystem_pb2.WatchDirRequest( + path=path, + recursive=recursive, + include_entry=include_entry, + allow_network_mounts=allow_network_mounts, + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + timeout=timeout, + headers={ + **authentication_header(self._envd_version, user), + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + ) + + try: + start_event = await events.__anext__() + + if not start_event.HasField("start"): + raise SandboxException( + f"Failed to start watch: expected start event, got {start_event}", + ) + + return AsyncWatchHandle( + events=events, + on_event=on_event, + on_exit=on_exit, + check_health=lambda: acheck_sandbox_health(self._envd_api), + ) + except Exception as e: + try: + await events.aclose() + except Exception: + pass + raise await _ahandle_filesystem_rpc_exception(e, self._envd_api) diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/watch_handle.py b/packages/python-sdk/e2b/sandbox_async/filesystem/watch_handle.py new file mode 100644 index 0000000..814b465 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/watch_handle.py @@ -0,0 +1,97 @@ +import asyncio +import inspect + +from typing import Any, AsyncGenerator, Awaitable, Callable, Optional + +from e2b.envd.rpc import ahandle_rpc_exception_with_health +from e2b.envd.filesystem.filesystem_pb2 import WatchDirResponse +from e2b.sandbox.filesystem.filesystem import map_entry_info +from e2b.sandbox.filesystem.watch_handle import FilesystemEvent, map_event_type +from e2b.sandbox_async.utils import OutputHandler + + +class AsyncWatchHandle: + """ + Handle for watching a directory in the sandbox filesystem. + + Use `.stop()` to stop watching the directory. + """ + + def __init__( + self, + events: AsyncGenerator[WatchDirResponse, Any], + on_event: OutputHandler[FilesystemEvent], + on_exit: Optional[OutputHandler[Optional[Exception]]] = None, + check_health: Optional[Callable[[], Awaitable[Optional[bool]]]] = None, + ): + self._events = events + self._on_event = on_event + self._on_exit = on_exit + self._check_health = check_health + + self._wait = asyncio.create_task(self._handle_events()) + + async def stop(self): + """ + Stop watching the directory. + """ + self._wait.cancel() + await asyncio.wait([self._wait]) + try: + await self._events.aclose() + except Exception: + pass + + async def _iterate_events(self): + try: + async for event in self._events: + if event.HasField("filesystem"): + event_type = map_event_type(event.filesystem.type) + if event_type: + yield FilesystemEvent( + name=event.filesystem.name, + type=event_type, + entry=( + map_entry_info(event.filesystem.entry) + if event.filesystem.HasField("entry") + else None + ), + ) + except Exception as e: + raise await ahandle_rpc_exception_with_health(e, self._check_health) + + async def _call_on_exit(self, error: Optional[Exception]): + if self._on_exit is None: + return + try: + cb = self._on_exit(error) + if inspect.isawaitable(cb): + await cb + except Exception: + # `on_exit` is the terminal callback; an error it raises has nowhere + # to propagate in this background task, so it's swallowed to avoid an + # "Task exception was never retrieved" warning. A `CancelledError` + # (a `BaseException`) is intentionally not caught here. + pass + + async def _handle_events(self): + error: Optional[Exception] = None + try: + async for event in self._iterate_events(): + cb = self._on_event(event) + if inspect.isawaitable(cb): + await cb + except asyncio.CancelledError: + # `stop()` cancels this task to end the watch. Treat it as a clean, + # user-initiated end: fire `on_exit` (with no error), then propagate + # the cancellation so the task still finishes as cancelled. + await self._call_on_exit(None) + raise + except Exception as e: + error = e + + # `on_exit` fires exactly once when the watch ends — with the error when + # the stream failed, or with `None` on a clean end. This matches the JS + # SDK, which calls `onExit()` after the loop completes and `onExit(err)` + # on error. + await self._call_on_exit(error) diff --git a/packages/python-sdk/e2b/sandbox_async/git.py b/packages/python-sdk/e2b/sandbox_async/git.py new file mode 100644 index 0000000..b57fb5a --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_async/git.py @@ -0,0 +1,1100 @@ +from typing import Dict, List, Optional + +from e2b.exceptions import ( + GitAuthException, + GitUpstreamException, + InvalidArgumentException, +) +from e2b.sandbox.commands.command_handle import CommandExitException +from e2b.sandbox._git import ( + GitBranches, + GitResetMode, + GitStatus, + build_add_args, + build_auth_error_message, + build_branches_args, + build_checkout_branch_args, + build_clone_plan, + build_commit_args, + build_credential_approve_command, + build_create_branch_args, + build_delete_branch_args, + build_git_command, + build_has_upstream_args, + build_pull_args, + build_push_args, + build_remote_add_args, + build_remote_add_shell_command, + build_remote_get_command, + build_remote_get_url_args, + build_remote_set_url_args, + build_reset_args, + build_restore_args, + build_status_args, + build_upstream_error_message, + is_auth_failure, + is_missing_upstream, + parse_git_branches, + parse_git_status, + parse_remote_url, + resolve_config_scope, + with_credentials, +) +from e2b.sandbox_async.commands.command import Commands + + +DEFAULT_GIT_ENV = {"GIT_TERMINAL_PROMPT": "0"} + + +class Git: + """ + Async module for running git operations in the sandbox. + """ + + def __init__(self, commands: Commands) -> None: + """ + Create a Git helper bound to the sandbox command runner. + + :param commands: Command runner used to execute git commands + """ + self._commands = commands + + async def _run_git( + self, + args: List[str], + repo_path: Optional[str], + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Build and execute a git command inside the sandbox. + + :param args: Git arguments to pass to the git binary + :param repo_path: Repository path used with `git -C`, if provided + :param envs: Extra environment variables for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + cmd = build_git_command(args, repo_path) + merged_envs = {**DEFAULT_GIT_ENV, **(envs or {})} + return await self._commands.run( + cmd, + envs=merged_envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) + + async def _run_shell( + self, + cmd: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Execute a raw shell command while applying default git environment variables. + + :param cmd: Shell command to execute + :param envs: Extra environment variables for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + merged_envs = {**DEFAULT_GIT_ENV, **(envs or {})} + return await self._commands.run( + cmd, + envs=merged_envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) + + async def _has_upstream( + self, + path: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> bool: + try: + result = await self._run_git( + build_has_upstream_args(), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return bool(result.stdout.strip()) + except Exception: + return False + + async def _resolve_remote_name( + self, + path: str, + remote: Optional[str], + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> str: + if remote: + return remote + + result = await self._run_git( + ["remote"], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + remotes = [line.strip() for line in result.stdout.splitlines() if line.strip()] + if len(remotes) == 1: + return remotes[0] + + raise InvalidArgumentException( + "Remote is required when using username/password and the repository has multiple remotes." + ) + + async def _with_remote_credentials( + self, + path: str, + remote: str, + username: str, + password: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + operation=None, + ): + original_url = await self._get_remote_url( + path, remote, envs, user, cwd, timeout, request_timeout + ) + credential_url = with_credentials(original_url, username, password) + await self._run_git( + ["remote", "set-url", remote, credential_url], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + result = None + operation_error: Exception | None = None + try: + if operation is None: + raise InvalidArgumentException("Operation is required.") + result = await operation() + except Exception as err: + operation_error = err + + restore_error: Exception | None = None + try: + await self._run_git( + ["remote", "set-url", remote, original_url], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + except Exception as err: + restore_error = err + + if operation_error: + raise operation_error + if restore_error: + raise restore_error + + return result + + async def _get_remote_url( + self, + path: str, + remote: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> str: + result = await self._run_git( + build_remote_get_url_args(remote), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return parse_remote_url(result.stdout, remote) + + async def clone( + self, + url: str, + path: Optional[str] = None, + branch: Optional[str] = None, + depth: Optional[int] = None, + username: Optional[str] = None, + password: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + dangerously_store_credentials: bool = False, + ): + """ + Clone a git repository into the sandbox. + + :param url: Git repository URL + :param path: Destination path for the clone + :param branch: Branch to check out + :param depth: If set, perform a shallow clone with this depth + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :param dangerously_store_credentials: Store credentials in the cloned repository when True + :return: Command result from the command runner + """ + if password and not username: + raise InvalidArgumentException( + "Username is required when using a password or token for git clone." + ) + + async def attempt_clone( + auth_username: Optional[str], auth_password: Optional[str] + ): + plan = build_clone_plan( + url=url, + path=path, + branch=branch, + depth=depth, + auth_username=auth_username, + auth_password=auth_password, + dangerously_store_credentials=dangerously_store_credentials, + ) + result = await self._run_git( + plan.args, None, envs, user, cwd, timeout, request_timeout + ) + if plan.should_strip and plan.repo_path and plan.sanitized_url: + await self._run_git( + build_remote_set_url_args("origin", plan.sanitized_url), + plan.repo_path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return result + + try: + return await attempt_clone(username, password) + except CommandExitException as err: + if is_auth_failure(err): + raise GitAuthException( + build_auth_error_message("clone", bool(username) and not password) + ) from err + raise + + async def init( + self, + path: str, + bare: bool = False, + initial_branch: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Initialize a new git repository. + + :param path: Destination path for the repository + :param bare: Create a bare repository when True + :param initial_branch: Initial branch name (for example, "main") + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = ["init"] + if initial_branch: + args.extend(["--initial-branch", initial_branch]) + if bare: + args.append("--bare") + args.append(path) + return await self._run_git( + args, None, envs, user, cwd, timeout, request_timeout + ) + + async def remote_add( + self, + path: str, + name: str, + url: str, + fetch: bool = False, + overwrite: bool = False, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Add (or update) a remote for a repository. + + :param path: Repository path + :param name: Remote name (for example, "origin") + :param url: Remote URL + :param fetch: Fetch the remote after adding it when True + :param overwrite: Overwrite the remote URL if it already exists when True + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = build_remote_add_args(name, url, fetch) + + if not overwrite: + return await self._run_git( + args, path, envs, user, cwd, timeout, request_timeout + ) + + cmd = build_remote_add_shell_command(args, path, name, url, fetch) + return await self._run_shell( + cmd, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + async def remote_get( + self, + path: str, + name: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> Optional[str]: + """ + Get the URL for a git remote. + + Returns `None` when the remote does not exist. + + :param path: Repository path + :param name: Remote name (for example, "origin") + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Remote URL if present, otherwise `None` + """ + cmd = build_remote_get_command(path, name) + result = ( + await self._run_shell( + cmd, + envs, + user, + cwd, + timeout, + request_timeout, + ) + ).stdout.strip() + return result or None + + async def status( + self, + path: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> GitStatus: + """ + Get repository status information. + + :param path: Repository path + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Parsed git status + """ + result = await self._run_git( + build_status_args(), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return parse_git_status(result.stdout) + + async def branches( + self, + path: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> GitBranches: + """ + List branches in a repository. + + :param path: Repository path + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Parsed branch list + """ + result = await self._run_git( + build_branches_args(), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return parse_git_branches(result.stdout) + + async def create_branch( + self, + path: str, + branch: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Create and check out a new branch. + + :param path: Repository path + :param branch: Branch name to create + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + return await self._run_git( + build_create_branch_args(branch), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + async def checkout_branch( + self, + path: str, + branch: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Check out an existing branch. + + :param path: Repository path + :param branch: Branch name to check out + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + return await self._run_git( + build_checkout_branch_args(branch), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + async def delete_branch( + self, + path: str, + branch: str, + force: bool = False, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Delete a branch. + + :param path: Repository path + :param branch: Branch name to delete + :param force: Force deletion with `-D` when `True` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = build_delete_branch_args(branch, force) + return await self._run_git( + args, path, envs, user, cwd, timeout, request_timeout + ) + + async def add( + self, + path: str, + files: Optional[List[str]] = None, + all: bool = True, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Stage files for commit. + + :param path: Repository path + :param files: Files to add; when omitted, adds the current directory + :param all: When `True` and `files` is omitted, stage all changes + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = build_add_args(files, all) + return await self._run_git( + args, path, envs, user, cwd, timeout, request_timeout + ) + + async def commit( + self, + path: str, + message: str, + author_name: Optional[str] = None, + author_email: Optional[str] = None, + allow_empty: bool = False, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Create a commit in the repository. + + :param path: Repository path + :param message: Commit message + :param author_name: Commit author name + :param author_email: Commit author email + :param allow_empty: Allow empty commits when `True` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = build_commit_args(message, author_name, author_email, allow_empty) + return await self._run_git( + args, path, envs, user, cwd, timeout, request_timeout + ) + + async def reset( + self, + path: str, + mode: Optional[GitResetMode] = None, + target: Optional[str] = None, + paths: Optional[List[str]] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Reset the current HEAD to a specified state. + + :param path: Repository path + :param mode: Reset mode (soft, mixed, hard, merge, keep) + :param target: Commit, branch, or ref to reset to (defaults to HEAD) + :param paths: Paths to reset + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = build_reset_args(mode, target, paths) + return await self._run_git( + args, path, envs, user, cwd, timeout, request_timeout + ) + + async def restore( + self, + path: str, + paths: List[str], + staged: Optional[bool] = None, + worktree: Optional[bool] = None, + source: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Restore working tree files or unstage changes. + + :param path: Repository path + :param paths: Paths to restore (use ["."] for all) + :param staged: When True, restore the index (unstage) + :param worktree: When True, restore working tree files + :param source: Restore from the given source (commit, branch, or ref) + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = build_restore_args(paths, staged, worktree, source) + return await self._run_git( + args, path, envs, user, cwd, timeout, request_timeout + ) + + async def push( + self, + path: str, + remote: Optional[str] = None, + branch: Optional[str] = None, + set_upstream: bool = True, + username: Optional[str] = None, + password: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Push commits to a remote. + + :param path: Repository path + :param remote: Remote name, e.g. `origin` + :param branch: Branch name to push + :param set_upstream: Set upstream tracking when `True` + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if password and not username: + raise InvalidArgumentException( + "Username is required when using a password or token for git push." + ) + + if username and password: + remote_name = await self._resolve_remote_name( + path, remote, envs, user, cwd, timeout, request_timeout + ) + return await self._with_remote_credentials( + path, + remote_name, + username, + password, + envs, + user, + cwd, + timeout, + request_timeout, + operation=lambda: self._run_git( + build_push_args( + remote_name, + remote=remote, + branch=branch, + set_upstream=set_upstream, + ), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ), + ) + + try: + return await self._run_git( + build_push_args( + None, + remote=remote, + branch=branch, + set_upstream=set_upstream, + ), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + except CommandExitException as err: + if is_auth_failure(err): + raise GitAuthException( + build_auth_error_message("push", bool(username) and not password) + ) from err + if is_missing_upstream(err): + raise GitUpstreamException( + build_upstream_error_message("push") + ) from err + raise + + async def pull( + self, + path: str, + remote: Optional[str] = None, + branch: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Pull changes from a remote. + + :param path: Repository path + :param remote: Remote name, e.g. `origin` + :param branch: Branch name to pull + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if password and not username: + raise InvalidArgumentException( + "Username is required when using a password or token for git pull." + ) + + if not remote and not branch: + has_upstream = await self._has_upstream( + path, envs, user, cwd, timeout, request_timeout + ) + if not has_upstream: + raise GitUpstreamException(build_upstream_error_message("pull")) + + if username and password: + remote_name = await self._resolve_remote_name( + path, remote, envs, user, cwd, timeout, request_timeout + ) + return await self._with_remote_credentials( + path, + remote_name, + username, + password, + envs, + user, + cwd, + timeout, + request_timeout, + operation=lambda: self._run_git( + build_pull_args(remote, branch, remote_name), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ), + ) + + try: + return await self._run_git( + build_pull_args(remote, branch), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + except CommandExitException as err: + if is_auth_failure(err): + raise GitAuthException( + build_auth_error_message("pull", bool(username) and not password) + ) from err + if is_missing_upstream(err): + raise GitUpstreamException( + build_upstream_error_message("pull") + ) from err + raise + + async def set_config( + self, + key: str, + value: str, + scope: str = "global", + path: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Set a git config value. + + Use `scope="local"` together with `path` to configure a specific repository. + + :param key: Git config key (e.g. `pull.rebase`) + :param value: Git config value + :param scope: Config scope: `global`, `local`, or `system` + :param path: Repository path required when `scope` is `local` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if not key: + raise InvalidArgumentException("Git config key is required.") + + scope_flag, repo_path = resolve_config_scope(scope, path) + return await self._run_git( + ["config", scope_flag, key, value], + repo_path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + async def get_config( + self, + key: str, + scope: str = "global", + path: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> Optional[str]: + """ + Get a git config value. + + Returns `None` when the key is not set in the requested scope. + + :param key: Git config key (e.g. `pull.rebase`) + :param scope: Config scope: `global`, `local`, or `system` + :param path: Repository path required when `scope` is `local` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Config value if present, otherwise `None` + """ + if not key: + raise InvalidArgumentException("Git config key is required.") + + scope_flag, repo_path = resolve_config_scope(scope, path) + cmd = ( + f"{build_git_command(['config', scope_flag, '--get', key], repo_path)} " + "|| true" + ) + result = ( + await self._run_shell( + cmd, + envs, + user, + cwd, + timeout, + request_timeout, + ) + ).stdout.strip() + return result or None + + async def dangerously_authenticate( + self, + username: str, + password: str, + host: str = "github.com", + protocol: str = "https", + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Dangerously authenticate git globally via the credential helper. + + This persists credentials in the credential store and may be accessible to agents running on the sandbox. + Prefer short-lived credentials when possible. + + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :param host: Host to authenticate for, defaults to `github.com` + :param protocol: Protocol to authenticate for, defaults to `https` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if not username or not password: + raise InvalidArgumentException( + "Both username and password are required to authenticate git." + ) + + await self.set_config( + "credential.helper", + "store", + scope="global", + envs=envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) + approve_cmd = build_credential_approve_command( + username=username, + password=password, + host=host, + protocol=protocol, + ) + return await self._run_shell( + approve_cmd, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + async def configure_user( + self, + name: str, + email: str, + scope: str = "global", + path: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Configure git user name and email. + + :param name: Git user name + :param email: Git user email + :param scope: Config scope: `global`, `local`, or `system` + :param path: Repository path required when `scope` is `local` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if not name or not email: + raise InvalidArgumentException("Both name and email are required.") + + await self.set_config( + "user.name", + name, + scope=scope, + path=path, + envs=envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) + return await self.set_config( + "user.email", + email, + scope=scope, + path=path, + envs=envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py new file mode 100644 index 0000000..791b9b3 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -0,0 +1,987 @@ +import datetime +import json +import logging +import shlex +import uuid +from typing import Dict, List, Optional, Union, overload + +import httpx +from packaging.version import Version +from typing_extensions import Self, Unpack + +from e2b.api import make_async_logging_event_hooks +from e2b.api.client.types import Unset +from e2b.api.client_async import get_envd_transport as get_transport +from e2b.connection_config import ApiParams, ConnectionConfig +from e2b.envd.api import ENVD_API_HEALTH_ROUTE, ahandle_envd_api_exception +from e2b.envd.versions import ENVD_DEBUG_FALLBACK +from e2b.exceptions import ( + TemplateException, + format_request_timeout_error, +) +from e2b.sandbox.main import SandboxOpts +from e2b.sandbox.sandbox_api import ( + McpServer, + SandboxLifecycle, + SandboxMetrics, + SandboxNetworkOpts, + SandboxNetworkUpdate, + SnapshotInfo, +) +from e2b.sandbox.utils import class_method_variant +from e2b.sandbox_async.commands.command import Commands +from e2b.sandbox_async.commands.pty import Pty +from e2b.sandbox_async.filesystem.filesystem import Filesystem +from e2b.sandbox_async.git import Git +from e2b.sandbox_async.sandbox_api import SandboxApi, SandboxInfo +from e2b.sandbox_async.paginator import AsyncSnapshotPaginator +from e2b.volume.volume_async import AsyncVolume +from e2b.api.client.models import SandboxVolumeMount as SandboxVolumeMountAPI + +logger = logging.getLogger(__name__) + +SandboxAsyncVolumeMount = Dict[str, Union[AsyncVolume, str]] + + +class AsyncSandbox(SandboxApi): + """ + E2B cloud sandbox is a secure and isolated cloud environment. + + The sandbox allows you to: + - Access Linux OS + - Create, list, and delete files and directories + - Run commands + - Run isolated code + - Access the internet + + Check docs [here](https://e2b.dev/docs). + + Use the `AsyncSandbox.create()` to create a new sandbox. + + Example: + ```python + from e2b import AsyncSandbox + + sandbox = await AsyncSandbox.create() + ``` + """ + + @property + def files(self) -> Filesystem: + """ + Module for interacting with the sandbox filesystem. + """ + return self._filesystem + + @property + def commands(self) -> Commands: + """ + Module for running commands in the sandbox. + """ + return self._commands + + @property + def pty(self) -> Pty: + """ + Module for interacting with the sandbox pseudo-terminal. + """ + return self._pty + + @property + def git(self) -> Git: + """ + Module for running git operations in the sandbox. + """ + return self._git + + def __init__( + self, + **opts: Unpack[SandboxOpts], + ): + """ + :deprecated: This constructor is deprecated + + Use `AsyncSandbox.create()` to create a new sandbox instead. + """ + super().__init__(**opts) + + self._transport = get_transport(self.connection_config) + self._envd_api = httpx.AsyncClient( + base_url=self.envd_api_url, + transport=self._transport, + headers=self.connection_config.sandbox_headers, + event_hooks=make_async_logging_event_hooks(self.connection_config.logger), + ) + self._filesystem = Filesystem( + self.envd_api_url, + self._envd_version, + self.connection_config, + self._transport.pool, + self._envd_api, + ) + self._commands = Commands( + self.envd_api_url, + self.connection_config, + self._transport.pool, + self._envd_version, + self._envd_api, + ) + self._pty = Pty( + self.envd_api_url, + self.connection_config, + self._transport.pool, + self._envd_version, + self._envd_api, + ) + self._git = Git(self._commands) + + async def is_running(self, request_timeout: Optional[float] = None) -> bool: + """ + Check if the sandbox is running. + + :param request_timeout: Timeout for the request in **seconds** + + :return: `True` if the sandbox is running, `False` otherwise + + Example + ```python + sandbox = await AsyncSandbox.create() + await sandbox.is_running() # Returns True + + await sandbox.kill() + await sandbox.is_running() # Returns False + ``` + """ + try: + r = await self._envd_api.get( + ENVD_API_HEALTH_ROUTE, + timeout=self.connection_config.get_request_timeout(request_timeout), + ) + + if r.status_code == 502: + return False + + err = await ahandle_envd_api_exception(r) + + if err: + raise err + + except httpx.TimeoutException: + raise format_request_timeout_error() + + return True + + @classmethod + async def create( + cls, + template: Optional[str] = None, + timeout: Optional[int] = None, + metadata: Optional[Dict[str, str]] = None, + envs: Optional[Dict[str, str]] = None, + secure: bool = True, + allow_internet_access: bool = True, + mcp: Optional[McpServer] = None, + network: Optional[SandboxNetworkOpts] = None, + lifecycle: Optional[SandboxLifecycle] = None, + volume_mounts: Optional[SandboxAsyncVolumeMount] = None, + logger: Optional[logging.Logger] = None, + **opts: Unpack[ApiParams], + ) -> Self: + """ + Create a new sandbox. + + By default, the sandbox is created from the default `base` sandbox template. + + :param template: Sandbox template name or ID + :param timeout: Timeout for the sandbox in **seconds**, default to 300 seconds. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + :param metadata: Custom metadata for the sandbox + :param envs: Custom environment variables for the sandbox + :param secure: Envd is secured with access token and cannot be used without it, defaults to `True`. + :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. + :param mcp: MCP server to enable in the sandbox + :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``. + :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` (default) or ``"pause"``, or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` (default ``True``) set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); ``auto_resume``: ``False`` (default) or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}`` + :param volume_mounts: Dictionary mapping mount paths to AsyncVolume instances or volume names + :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. + + :return: A Sandbox instance for the new sandbox + + Use this method instead of using the constructor to create a new sandbox. + """ + if not template and mcp is not None: + template = cls.default_mcp_template + elif not template: + template = cls.default_template + + transformed_mounts: Optional[List[SandboxVolumeMountAPI]] = None + if volume_mounts: + transformed_mounts = [ + SandboxVolumeMountAPI( + name=vol.name if isinstance(vol, AsyncVolume) else vol, + path=path, + ) + for path, vol in volume_mounts.items() + ] + + sandbox = await cls._create( + template=template, + timeout=timeout, + metadata=metadata, + envs=envs, + secure=secure, + allow_internet_access=allow_internet_access, + mcp=mcp, + network=network, + lifecycle=lifecycle, + volume_mounts=transformed_mounts, + logger=logger, + **opts, + ) + + if mcp is not None: + token = str(uuid.uuid4()) + sandbox._mcp_token = token + + res = await sandbox.commands.run( + f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}", + user="root", + envs={"GATEWAY_ACCESS_TOKEN": token}, + ) + if res.exit_code != 0: + raise Exception(f"Failed to start MCP gateway: {res.stderr}") + + return sandbox + + @overload + async def connect( + self, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> Self: + """ + Connect to a sandbox. If the sandbox is paused, it will be automatically resumed. + Sandbox must be either running or be paused. + + With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc). + + :param timeout: Timeout for the sandbox in **seconds** + For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :return: A running sandbox instance + + @example + ```python + sandbox = await AsyncSandbox.create() + await sandbox.pause() + + # Another code block + same_sandbox = await sandbox.connect() + ``` + """ + ... + + @overload + @staticmethod + async def connect( + sandbox_id: str, + timeout: Optional[int] = None, + logger: Optional[logging.Logger] = None, + **opts: Unpack[ApiParams], + ) -> "AsyncSandbox": + """ + Connect to a sandbox. If the sandbox is paused, it will be automatically resumed. + Sandbox must be either running or be paused. + + With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc). + + :param sandbox_id: Sandbox ID + :param timeout: Timeout for the sandbox in **seconds** + For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. + :return: A running sandbox instance + + @example + ```python + sandbox = await AsyncSandbox.create() + await AsyncSandbox.pause(sandbox.sandbox_id) + + # Another code block + same_sandbox = await AsyncSandbox.connect(sandbox.sandbox_id) + ``` + """ + ... + + @class_method_variant("_cls_connect_sandbox") + async def connect( + self, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> Self: + """ + Connect to a sandbox. If the sandbox is paused, it will be automatically resumed. + Sandbox must be either running or be paused. + + With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc). + + :param timeout: Timeout for the sandbox in **seconds** + For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :return: A running sandbox instance + + @example + ```python + sandbox = await AsyncSandbox.create() + await sandbox.pause() + + # Another code block + same_sandbox = await sandbox.connect() + ``` + """ + if self.connection_config.debug: + # Skip connecting to the sandbox in debug mode + return self + + await SandboxApi._cls_connect( + sandbox_id=self.sandbox_id, + timeout=timeout, + **self.connection_config.get_api_params(**opts), + ) + + return self + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.kill() + + @overload + async def kill( + self, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Kill the sandbox. + + :return: `True` if the sandbox was killed, `False` if the sandbox was not found + """ + ... + + @overload + @staticmethod + async def kill( + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Kill the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + + :return: `True` if the sandbox was killed, `False` if the sandbox was not found + """ + ... + + @class_method_variant("_cls_kill") + async def kill( + self, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Kill the sandbox specified by sandbox ID. + + :return: `True` if the sandbox was killed, `False` if the sandbox was not found + """ + if self.connection_config.debug: + # Skip killing the sandbox in debug mode + return True + + return await SandboxApi._cls_kill( + sandbox_id=self.sandbox_id, + **self.connection_config.get_api_params(**opts), + ) + + @overload + async def set_timeout( + self, + timeout: int, + **opts: Unpack[ApiParams], + ) -> None: + """ + Set the timeout of the sandbox. + This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.set_timeout`. + + The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + + :param timeout: Timeout for the sandbox in **seconds** + """ + ... + + @overload + @staticmethod + async def set_timeout( + sandbox_id: str, + timeout: int, + **opts: Unpack[ApiParams], + ) -> None: + """ + Set the timeout of the specified sandbox. + This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.set_timeout`. + + The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + + :param sandbox_id: Sandbox ID + :param timeout: Timeout for the sandbox in **seconds** + """ + ... + + @class_method_variant("_cls_set_timeout") + async def set_timeout( + self, + timeout: int, + **opts: Unpack[ApiParams], + ) -> None: + """ + Set the timeout of the specified sandbox. + This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.set_timeout`. + + The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + + :param timeout: Timeout for the sandbox in **seconds** + """ + await SandboxApi._cls_set_timeout( + sandbox_id=self.sandbox_id, + timeout=timeout, + **self.connection_config.get_api_params(**opts), + ) + + @overload + async def update_network( + self, + network: SandboxNetworkUpdate, + **opts: Unpack[ApiParams], + ) -> None: + """ + Update the network configuration of the sandbox. + + Replaces the current egress configuration atomically — fields that are + omitted are cleared on the server. + + :param network: New network configuration. + """ + ... + + @overload + @staticmethod + async def update_network( + sandbox_id: str, + network: SandboxNetworkUpdate, + **opts: Unpack[ApiParams], + ) -> None: + """ + Update the network configuration of the sandbox specified by sandbox ID. + + Replaces the current egress configuration atomically — fields that are + omitted are cleared on the server. + + :param sandbox_id: Sandbox ID. + :param network: New network configuration. + """ + ... + + @class_method_variant("_cls_update_network") + async def update_network( + self, + network: SandboxNetworkUpdate, + **opts: Unpack[ApiParams], + ) -> None: + """ + Update the network configuration of the sandbox. + + Replaces the current egress configuration atomically — fields that are + omitted are cleared on the server. + + :param network: New network configuration. + """ + await SandboxApi._cls_update_network( + sandbox_id=self.sandbox_id, + network=network, + **self.connection_config.get_api_params(**opts), + ) + + @overload + async def get_info( + self, + **opts: Unpack[ApiParams], + ) -> SandboxInfo: + """ + Get sandbox information like sandbox ID, template, metadata, started at/end at date. + + :return: Sandbox info + """ + ... + + @overload + @staticmethod + async def get_info( + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> SandboxInfo: + """ + Get sandbox information like sandbox ID, template, metadata, started at/end at date. + :param sandbox_id: Sandbox ID + + :return: Sandbox info + """ + ... + + @class_method_variant("_cls_get_info") + async def get_info( + self, + **opts: Unpack[ApiParams], + ) -> SandboxInfo: + """ + Get sandbox information like sandbox ID, template, metadata, started at/end at date. + + :return: Sandbox info + """ + + return await SandboxApi._cls_get_info( + sandbox_id=self.sandbox_id, + **self.connection_config.get_api_params(**opts), + ) + + @overload + async def get_metrics( + self, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + **opts: Unpack[ApiParams], + ) -> List[SandboxMetrics]: + """ + Get the metrics of the current sandbox. + + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to the current time + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + ... + + @overload + @staticmethod + async def get_metrics( + sandbox_id: str, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + **opts: Unpack[ApiParams], + ) -> List[SandboxMetrics]: + """ + Get the metrics of the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to the current time + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + ... + + @class_method_variant("_cls_get_metrics") + async def get_metrics( + self, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + **opts: Unpack[ApiParams], + ) -> List[SandboxMetrics]: + """ + Get the metrics of the current sandbox. + + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to the current time + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + if self.connection_config.debug: + # Skip getting the metrics in debug mode + return [] + + if self._envd_version < Version("0.1.5"): + raise TemplateException( + "You need to update the template to use the new SDK." + ) + + if self._envd_version < Version("0.2.4"): + logger.warning( + "Disk metrics are not supported in this version of the sandbox, please rebuild the template to get disk metrics." + ) + + return await SandboxApi._cls_get_metrics( + sandbox_id=self.sandbox_id, + start=start, + end=end, + **self.connection_config.get_api_params(**opts), + ) + + @overload + async def pause( + self, + keep_memory: bool = True, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Pause the sandbox. + + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. Defaults to `True`. + + :return: `True` if the sandbox got paused, `False` if the sandbox was already paused + """ + ... + + @overload + @staticmethod + async def pause( + sandbox_id: str, + keep_memory: bool = True, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Pause the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. Defaults to `True`. + + :return: `True` if the sandbox got paused, `False` if the sandbox was already paused + """ + ... + + @class_method_variant("_cls_pause") + async def pause( + self, + keep_memory: bool = True, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Pause the sandbox. + + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. Defaults to `True` (full memory snapshot). + + :return: `True` if the sandbox got paused, `False` if the sandbox was already paused + """ + + return await SandboxApi._cls_pause( + sandbox_id=self.sandbox_id, + keep_memory=keep_memory, + **self.connection_config.get_api_params(**opts), + ) + + @overload + async def beta_pause( + self, + keep_memory: bool = True, + **opts: Unpack[ApiParams], + ) -> bool: ... + + @overload + @staticmethod + async def beta_pause( + sandbox_id: str, + keep_memory: bool = True, + **opts: Unpack[ApiParams], + ) -> bool: ... + + @class_method_variant("_cls_pause") + async def beta_pause( + self, + keep_memory: bool = True, + **opts: Unpack[ApiParams], + ) -> bool: + """ + :deprecated: Use `pause()` instead. + + :return: `True` if the sandbox got paused, `False` if the sandbox was already paused + """ + return await self.pause(keep_memory=keep_memory, **opts) + + @overload + async def create_snapshot( + self, + name: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SnapshotInfo: + """ + Create a snapshot of the sandbox's current state. + + The sandbox will be paused while the snapshot is being created. + The snapshot can be used to create new sandboxes with the same filesystem and state. + Snapshots are persistent and survive sandbox deletion. + + Use the returned `snapshot_id` with `AsyncSandbox.create(snapshot_id)` to create a new sandbox from the snapshot. + + :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + + :return: Snapshot information including the snapshot ID and names + """ + ... + + @overload + @staticmethod + async def create_snapshot( + sandbox_id: str, + name: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SnapshotInfo: + """ + Create a snapshot from the sandbox specified by sandbox ID. + + The sandbox will be paused while the snapshot is being created. + + :param sandbox_id: Sandbox ID + :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + + :return: Snapshot information including the snapshot ID and names + """ + ... + + @class_method_variant("_cls_create_snapshot") + async def create_snapshot( + self, + name: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SnapshotInfo: + """ + Create a snapshot of the sandbox's current state. + + The sandbox will be paused while the snapshot is being created. + The snapshot can be used to create new sandboxes with the same filesystem and state. + Snapshots are persistent and survive sandbox deletion. + + Use the returned `snapshot_id` with `AsyncSandbox.create(snapshot_id)` to create a new sandbox from the snapshot. + + :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + + :return: Snapshot information including the snapshot ID and names + """ + return await SandboxApi._cls_create_snapshot( + sandbox_id=self.sandbox_id, + name=name, + **self.connection_config.get_api_params(**opts), + ) + + @overload + def list_snapshots( + self, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> AsyncSnapshotPaginator: + """ + List snapshots for this sandbox. + + :param limit: Maximum number of snapshots to return per page + :param next_token: Token for pagination + + :return: Paginator for listing snapshots + """ + ... + + @overload + @staticmethod + def list_snapshots( + sandbox_id: Optional[str] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> AsyncSnapshotPaginator: + """ + List all snapshots. + + :param sandbox_id: Filter snapshots by source sandbox ID + :param limit: Maximum number of snapshots to return per page + :param next_token: Token for pagination + + :return: Paginator for listing snapshots + """ + ... + + @class_method_variant("_cls_list_snapshots") + def list_snapshots( + self, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> AsyncSnapshotPaginator: + """ + List snapshots for this sandbox. + + :param limit: Maximum number of snapshots to return per page + :param next_token: Token for pagination + + :return: Paginator for listing snapshots + """ + return AsyncSnapshotPaginator( + sandbox_id=self.sandbox_id, + limit=limit, + next_token=next_token, + **self.connection_config.get_api_params(**opts), + ) + + @staticmethod + def _cls_list_snapshots( + sandbox_id: Optional[str] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> AsyncSnapshotPaginator: + return AsyncSnapshotPaginator( + sandbox_id=sandbox_id, + limit=limit, + next_token=next_token, + **opts, + ) + + @staticmethod + async def delete_snapshot( + snapshot_id: str, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Delete a snapshot. + + :param snapshot_id: Snapshot ID + :return: `True` if the snapshot was deleted, `False` if it was not found + """ + return await SandboxApi._cls_delete_snapshot( + snapshot_id=snapshot_id, + **opts, + ) + + async def get_mcp_token(self) -> Optional[str]: + """ + Get the MCP token for the sandbox. + + :return: MCP token for the sandbox, or None if MCP is not enabled. + """ + if not self._mcp_token: + self._mcp_token = await self.files.read( + "/etc/mcp-gateway/.token", user="root" + ) + return self._mcp_token + + @classmethod + async def _cls_connect_sandbox( + cls, + sandbox_id: str, + timeout: Optional[int] = None, + logger: Optional[logging.Logger] = None, + **opts: Unpack[ApiParams], + ) -> Self: + debug = ConnectionConfig(**opts).debug + if debug: + sandbox_domain = None + envd_version = ENVD_DEBUG_FALLBACK + envd_access_token = None + traffic_access_token = None + else: + sandbox = await SandboxApi._cls_connect( + sandbox_id=sandbox_id, + timeout=timeout, + logger=logger, + **opts, + ) + + sandbox_id = sandbox.sandbox_id + sandbox_domain = sandbox.sandbox_domain + envd_version = Version(sandbox.envd_version) + envd_access_token = sandbox.envd_access_token + traffic_access_token = sandbox.traffic_access_token + + sandbox_headers = { + "E2b-Sandbox-Id": sandbox_id, + "E2b-Sandbox-Port": str(ConnectionConfig.envd_port), + } + if envd_access_token is not None and not isinstance(envd_access_token, Unset): + sandbox_headers["X-Access-Token"] = envd_access_token + + connection_config = ConnectionConfig( + extra_sandbox_headers=sandbox_headers, + logger=logger, + **opts, + ) + + return cls( + sandbox_id=sandbox_id, + sandbox_domain=sandbox_domain, + envd_version=envd_version, + envd_access_token=envd_access_token, + traffic_access_token=traffic_access_token, + connection_config=connection_config, + ) + + @classmethod + async def _create( + cls, + template: Optional[str], + timeout: Optional[int], + metadata: Optional[Dict[str, str]], + envs: Optional[Dict[str, str]], + secure: bool, + allow_internet_access: bool, + mcp: Optional[McpServer] = None, + network: Optional[SandboxNetworkOpts] = None, + lifecycle: Optional[SandboxLifecycle] = None, + volume_mounts: Optional[list] = None, + logger: Optional[logging.Logger] = None, + **opts: Unpack[ApiParams], + ) -> Self: + extra_sandbox_headers = {} + + debug = ConnectionConfig(**opts).debug + if debug: + sandbox_id = "debug_sandbox_id" + sandbox_domain = None + envd_version = ENVD_DEBUG_FALLBACK + envd_access_token = None + traffic_access_token = None + else: + response = await SandboxApi._create_sandbox( + template=template or cls.default_template, + timeout=timeout or cls.default_sandbox_timeout, + metadata=metadata, + env_vars=envs, + secure=secure, + allow_internet_access=allow_internet_access, + mcp=mcp, + network=network, + lifecycle=lifecycle, + volume_mounts=volume_mounts, + logger=logger, + **opts, + ) + + sandbox_id = response.sandbox_id + sandbox_domain = response.sandbox_domain + envd_version = Version(response.envd_version) + envd_access_token = response.envd_access_token + traffic_access_token = response.traffic_access_token + + if envd_access_token is not None and not isinstance( + envd_access_token, Unset + ): + extra_sandbox_headers["X-Access-Token"] = envd_access_token + + extra_sandbox_headers["E2b-Sandbox-Id"] = sandbox_id + extra_sandbox_headers["E2b-Sandbox-Port"] = str(ConnectionConfig.envd_port) + + connection_config = ConnectionConfig( + extra_sandbox_headers=extra_sandbox_headers, + logger=logger, + **opts, + ) + + return cls( + sandbox_id=sandbox_id, + sandbox_domain=sandbox_domain, + envd_version=envd_version, + envd_access_token=envd_access_token, + traffic_access_token=traffic_access_token, + connection_config=connection_config, + ) diff --git a/packages/python-sdk/e2b/sandbox_async/paginator.py b/packages/python-sdk/e2b/sandbox_async/paginator.py new file mode 100644 index 0000000..269248c --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_async/paginator.py @@ -0,0 +1,140 @@ +import urllib.parse +from typing import Optional, List + +from typing_extensions import Unpack + +from e2b.api.client.api.sandboxes import get_v2_sandboxes +from e2b.api.client.api.snapshots import get_snapshots +from e2b.api.client.types import UNSET +from e2b.connection_config import ApiParams, ConnectionConfig +from e2b.exceptions import SandboxException +from e2b.sandbox.sandbox_api import ( + SandboxPaginatorBase, + SandboxInfo, + SnapshotPaginatorBase, + SnapshotInfo, +) +from e2b.api import handle_api_exception +from e2b.api.client.models.error import Error +from e2b.api.client_async import get_api_client + + +class AsyncSandboxPaginator(SandboxPaginatorBase): + """ + Paginator for listing sandboxes. + + Example: + ```python + paginator = AsyncSandbox.list() + + while paginator.has_next: + sandboxes = await paginator.next_items() + print(sandboxes) + ``` + """ + + async def next_items(self, **opts: Unpack[ApiParams]) -> List[SandboxInfo]: + """ + Returns the next page of sandboxes. + + Call this method only if `has_next` is `True`, otherwise it will raise an exception. + + :param opts: Per-call connection options (e.g. `api_key`, `domain`, + `headers`, `request_timeout`). When provided, this call uses these + options instead of the ones the paginator was constructed with. + + :returns: List of sandboxes + """ + if not self.has_next: + raise Exception("No more items to fetch") + + # Convert filters to the format expected by the API + metadata: Optional[str] = None + if self.query and self.query.metadata: + quoted_metadata = { + urllib.parse.quote(k): urllib.parse.quote(v) + for k, v in self.query.metadata.items() + } + metadata = urllib.parse.urlencode(quoted_metadata) + + config = ConnectionConfig(**{**self._opts, **opts}) + api_client = get_api_client(config) + res = await get_v2_sandboxes.asyncio_detailed( + client=api_client, + metadata=metadata if metadata else UNSET, + state=self.query.state if self.query and self.query.state else UNSET, + limit=self.limit if self.limit else UNSET, + next_token=self._next_token if self._next_token else UNSET, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + self._update_pagination(res.headers) + + if res.parsed is None: + return [] + + # Check if res.parsed is Error + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return [SandboxInfo._from_listed_sandbox(sandbox) for sandbox in res.parsed] + + +class AsyncSnapshotPaginator(SnapshotPaginatorBase): + """ + Paginator for listing snapshots. + + Example: + ```python + paginator = AsyncSandbox.list_snapshots() + + while paginator.has_next: + snapshots = await paginator.next_items() + print(snapshots) + ``` + """ + + async def next_items(self, **opts: Unpack[ApiParams]) -> List[SnapshotInfo]: + """ + Returns the next page of snapshots. + + Call this method only if `has_next` is `True`, otherwise it will raise an exception. + + :param opts: Per-call connection options (e.g. `api_key`, `domain`, + `headers`, `request_timeout`). When provided, this call uses these + options instead of the ones the paginator was constructed with. + + :returns: List of snapshots + """ + if not self.has_next: + raise Exception("No more items to fetch") + + config = ConnectionConfig(**{**self._opts, **opts}) + api_client = get_api_client(config) + res = await get_snapshots.asyncio_detailed( + client=api_client, + sandbox_id=self.sandbox_id if self.sandbox_id else UNSET, + limit=self.limit if self.limit else UNSET, + next_token=self._next_token if self._next_token else UNSET, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + self._update_pagination(res.headers) + + if res.parsed is None: + return [] + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return [ + SnapshotInfo( + snapshot_id=snapshot.snapshot_id, + names=list(snapshot.names) if snapshot.names else [], + ) + for snapshot in res.parsed + ] diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py new file mode 100644 index 0000000..ae55ca7 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -0,0 +1,504 @@ +import datetime +import logging +from typing import Any, Dict, List, Optional, cast + +from packaging.version import Version +from typing_extensions import Unpack + +from e2b.api import SandboxCreateResponse, handle_api_exception +from e2b.api.client.api.sandboxes import ( + delete_sandboxes_sandbox_id, + get_sandboxes_sandbox_id, + get_sandboxes_sandbox_id_metrics, + post_sandboxes, + post_sandboxes_sandbox_id_connect, + post_sandboxes_sandbox_id_pause, + post_sandboxes_sandbox_id_snapshots, + post_sandboxes_sandbox_id_timeout, + put_sandboxes_sandbox_id_network, +) +from e2b.api.client.api.templates import delete_templates_template_id +from e2b.api.client.models import ( + ConnectSandbox, + Error, + NewSandbox, + PostSandboxesSandboxIDSnapshotsBody, + PostSandboxesSandboxIDTimeoutBody, + SandboxAutoResumeConfig, + SandboxNetworkConfig, + SandboxPauseRequest, + SandboxVolumeMount as SandboxVolumeMountAPI, +) +from e2b.api.client.types import UNSET +from e2b.api.client_async import get_api_client +from e2b.connection_config import ApiParams, ConnectionConfig +from e2b.exceptions import ( + InvalidArgumentException, + SandboxException, + SandboxNotFoundException, + TemplateException, +) +from e2b.sandbox.main import SandboxBase +from e2b.sandbox.sandbox_api import ( + build_network_update_body, + McpServer, + SandboxInfo, + SandboxLifecycle, + SandboxMetrics, + SandboxNetworkOpts, + SandboxNetworkUpdate, + SandboxQuery, + SnapshotInfo, + build_network_config, +) +from e2b.sandbox_async.paginator import AsyncSandboxPaginator + + +class SandboxApi(SandboxBase): + @staticmethod + def list( + query: Optional[SandboxQuery] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> AsyncSandboxPaginator: + """ + List sandboxes. + + By default (no `query.state` set), returns sandboxes in both `running` + and `paused` states. To filter by state, pass `query=SandboxQuery(state=[...])`. + + :param query: Filter the list of sandboxes by metadata or state, e.g. `SandboxQuery(metadata={"key": "value"})` or `SandboxQuery(state=[SandboxState.RUNNING])` + :param limit: Maximum number of sandboxes to return per page + :param next_token: Token for pagination + + :return: An `AsyncSandboxPaginator` that yields pages of sandboxes (running and paused by default). Iterate pages via `await paginator.next_items()` while `paginator.has_next` is True. + """ + return AsyncSandboxPaginator( + query=query, + limit=limit, + next_token=next_token, + **opts, + ) + + @classmethod + async def _cls_get_info( + cls, + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> SandboxInfo: + """ + Get the sandbox info. + :param sandbox_id: Sandbox ID + + :return: Sandbox info + """ + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = await get_sandboxes_sandbox_id.asyncio_detailed( + sandbox_id, + client=api_client, + ) + + if res.status_code == 404: + raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return SandboxInfo._from_sandbox_detail(res.parsed) + + @classmethod + async def _cls_kill( + cls, + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> bool: + config = ConnectionConfig(**opts) + + if config.debug: + # Skip killing the sandbox in debug mode + return True + + api_client = get_api_client(config) + res = await delete_sandboxes_sandbox_id.asyncio_detailed( + sandbox_id, + client=api_client, + ) + + if res.status_code == 404: + return False + + if res.status_code >= 300: + raise handle_api_exception(res) + + return True + + @classmethod + async def _cls_set_timeout( + cls, + sandbox_id: str, + timeout: int, + **opts: Unpack[ApiParams], + ) -> None: + config = ConnectionConfig(**opts) + + if config.debug: + # Skip setting the timeout in debug mode + return + + api_client = get_api_client(config) + res = await post_sandboxes_sandbox_id_timeout.asyncio_detailed( + sandbox_id, + client=api_client, + body=PostSandboxesSandboxIDTimeoutBody(timeout=timeout), + ) + + if res.status_code == 404: + raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + @classmethod + async def _cls_update_network( + cls, + sandbox_id: str, + network: SandboxNetworkUpdate, + **opts: Unpack[ApiParams], + ) -> None: + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = await put_sandboxes_sandbox_id_network.asyncio_detailed( + sandbox_id, + client=api_client, + body=build_network_update_body(network), + ) + + if res.status_code == 404: + raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + @classmethod + async def _create_sandbox( + cls, + template: str, + timeout: int, + allow_internet_access: bool, + metadata: Optional[Dict[str, str]], + env_vars: Optional[Dict[str, str]], + secure: bool, + mcp: Optional[McpServer] = None, + network: Optional[SandboxNetworkOpts] = None, + lifecycle: Optional[SandboxLifecycle] = None, + volume_mounts: Optional[List[SandboxVolumeMountAPI]] = None, + logger: Optional[logging.Logger] = None, + **opts: Unpack[ApiParams], + ) -> SandboxCreateResponse: + config = ConnectionConfig(logger=logger, **opts) + + # on_timeout accepts a bare action or {"action", "keep_memory"}; normalize. + # Only the object form carries keep_memory; anything else (a bare action + # string, or an unexpected value from an untyped caller) passes through as + # the action, so a non-"pause" value resolves to kill instead of crashing. + on_timeout_raw = lifecycle.get("on_timeout", "kill") if lifecycle else "kill" + if isinstance(on_timeout_raw, dict): + on_timeout = on_timeout_raw.get("action", "kill") + keep_memory_provided = "keep_memory" in on_timeout_raw + keep_memory = on_timeout_raw.get("keep_memory") + else: + on_timeout = on_timeout_raw + keep_memory = None + keep_memory_provided = False + + # keep_memory only governs a pause action. The discriminated union type + # forbids it on action="kill"; re-check at runtime for callers that + # bypass the type. + if keep_memory_provided and on_timeout != "pause": + raise InvalidArgumentException( + "keep_memory is only allowed when on_timeout action is 'pause'." + ) + + # A missing or explicit None keep_memory defaults to True (full memory), + # mirroring the JS SDK; sending null would wrongly read as filesystem-only. + if keep_memory is None: + keep_memory = True + auto_resume = lifecycle.get("auto_resume", False) if lifecycle else False + + if auto_resume and on_timeout != "pause": + raise InvalidArgumentException( + "auto_resume can only be True when on_timeout action is 'pause'." + ) + + if not keep_memory and auto_resume: + raise InvalidArgumentException( + "auto_resume: True is not a valid value when keep_memory: False - " + "a filesystem-only snapshot cannot be auto-resumed by traffic and " + "must be resumed explicitly using Sandbox.connect()." + ) + + network_body = build_network_config(network) + body = NewSandbox( + template_id=template, + auto_pause=on_timeout == "pause", + auto_pause_memory=keep_memory if on_timeout == "pause" else UNSET, + auto_resume=SandboxAutoResumeConfig(enabled=auto_resume), + metadata=metadata or {}, + timeout=timeout, + env_vars=env_vars or {}, + mcp=cast(Any, mcp) or UNSET, + secure=secure, + allow_internet_access=allow_internet_access, + network=SandboxNetworkConfig(**network_body) if network_body else UNSET, + volume_mounts=volume_mounts if volume_mounts else UNSET, + ) + + api_client = get_api_client(config) + res = await post_sandboxes.asyncio_detailed( + body=body, + client=api_client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + if Version(res.parsed.envd_version) < Version("0.1.0"): + await SandboxApi._cls_kill(res.parsed.sandbox_id) + raise TemplateException( + "You need to update the template to use the new SDK." + ) + + domain = res.parsed.domain if isinstance(res.parsed.domain, str) else None + envd_token = ( + res.parsed.envd_access_token + if isinstance(res.parsed.envd_access_token, str) + else None + ) + traffic_token = ( + res.parsed.traffic_access_token + if isinstance(res.parsed.traffic_access_token, str) + else None + ) + + return SandboxCreateResponse( + sandbox_id=res.parsed.sandbox_id, + sandbox_domain=domain, + envd_version=res.parsed.envd_version, + envd_access_token=envd_token, + traffic_access_token=traffic_token, + ) + + @classmethod + async def _cls_get_metrics( + cls, + sandbox_id: str, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + **opts: Unpack[ApiParams], + ) -> List[SandboxMetrics]: + """ + Get the metrics of the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to the current time + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + config = ConnectionConfig(**opts) + + if config.debug: + # Skip getting the metrics in debug mode + return [] + + api_client = get_api_client(config) + res = await get_sandboxes_sandbox_id_metrics.asyncio_detailed( + sandbox_id, + start=int(start.timestamp()) if start else UNSET, + end=int(end.timestamp()) if end else UNSET, + client=api_client, + ) + + if res.status_code == 404: + raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + return [] + + # Check if res.parse is Error + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + # Convert to typed SandboxMetrics objects + return [ + SandboxMetrics( + cpu_count=metric.cpu_count, + cpu_used_pct=metric.cpu_used_pct, + disk_total=metric.disk_total, + disk_used=metric.disk_used, + mem_total=metric.mem_total, + mem_used=metric.mem_used, + mem_cache=metric.mem_cache, + timestamp=metric.timestamp, + ) + for metric in res.parsed + ] + + @classmethod + async def _cls_create_snapshot( + cls, + sandbox_id: str, + name: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SnapshotInfo: + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = await post_sandboxes_sandbox_id_snapshots.asyncio_detailed( + sandbox_id, + client=api_client, + body=PostSandboxesSandboxIDSnapshotsBody(name=name if name else UNSET), + ) + + if res.status_code == 404: + raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return SnapshotInfo( + snapshot_id=res.parsed.snapshot_id, + names=list(res.parsed.names) if res.parsed.names else [], + ) + + @classmethod + async def _cls_delete_snapshot( + cls, + snapshot_id: str, + **opts: Unpack[ApiParams], + ) -> bool: + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = await delete_templates_template_id.asyncio_detailed( + snapshot_id, + client=api_client, + ) + + if res.status_code == 404: + return False + + if res.status_code >= 300: + raise handle_api_exception(res) + + return True + + @classmethod + async def _cls_pause( + cls, + sandbox_id: str, + keep_memory: bool = True, + **opts: Unpack[ApiParams], + ) -> bool: + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = await post_sandboxes_sandbox_id_pause.asyncio_detailed( + sandbox_id, + client=api_client, + body=SandboxPauseRequest(memory=keep_memory), + ) + + if res.status_code == 404: + raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code == 409: + # Sandbox is already paused + return False + + if res.status_code >= 300: + raise handle_api_exception(res) + + # Check if res.parse is Error + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return True + + @classmethod + async def _cls_connect( + cls, + sandbox_id: str, + timeout: Optional[int] = None, + logger: Optional[logging.Logger] = None, + **opts: Unpack[ApiParams], + ) -> SandboxCreateResponse: + timeout = timeout or SandboxBase.default_sandbox_timeout + + # Sandbox is not running, resume it + config = ConnectionConfig(logger=logger, **opts) + + api_client = get_api_client(config) + res = await post_sandboxes_sandbox_id_connect.asyncio_detailed( + sandbox_id, + client=api_client, + body=ConnectSandbox(timeout=timeout), + ) + + if res.status_code == 404: + raise SandboxNotFoundException(f"Paused sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + # Check if res.parse is Error + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + if res.parsed is None: + raise Exception("Body of the request is None") + + domain = res.parsed.domain if isinstance(res.parsed.domain, str) else None + envd_token = ( + res.parsed.envd_access_token + if isinstance(res.parsed.envd_access_token, str) + else None + ) + traffic_token = ( + res.parsed.traffic_access_token + if isinstance(res.parsed.traffic_access_token, str) + else None + ) + + return SandboxCreateResponse( + sandbox_id=res.parsed.sandbox_id, + sandbox_domain=domain, + envd_version=res.parsed.envd_version, + envd_access_token=envd_token, + traffic_access_token=traffic_token, + ) diff --git a/packages/python-sdk/e2b/sandbox_async/utils.py b/packages/python-sdk/e2b/sandbox_async/utils.py new file mode 100644 index 0000000..9ed3256 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_async/utils.py @@ -0,0 +1,7 @@ +from typing import TypeVar, Union, Callable, Awaitable + +T = TypeVar("T") +OutputHandler = Union[ + Callable[[T], None], + Callable[[T], Awaitable[None]], +] diff --git a/packages/python-sdk/e2b/sandbox_domains.py b/packages/python-sdk/e2b/sandbox_domains.py new file mode 100644 index 0000000..0ac5fae --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_domains.py @@ -0,0 +1,5 @@ +SUPPORTED_SANDBOX_DOMAINS = ("e2b.app", "e2b.dev", "e2b.pro", "e2b-staging.dev") + + +def is_supported_sandbox_domain(sandbox_domain: str) -> bool: + return sandbox_domain in SUPPORTED_SANDBOX_DOMAINS diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/command.py b/packages/python-sdk/e2b/sandbox_sync/commands/command.py new file mode 100644 index 0000000..971d792 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_sync/commands/command.py @@ -0,0 +1,420 @@ +import threading +from typing import Callable, Dict, List, Literal, Optional, Union, overload + +import e2b_connect +import httpx +from packaging.version import Version +from e2b.api import make_logging_event_hooks +from e2b.api.client_sync import get_envd_transport +from e2b.connection_config import ( + ConnectionConfig, + Username, + KEEPALIVE_PING_HEADER, + KEEPALIVE_PING_INTERVAL_SEC, +) +from e2b.envd.process import process_connect, process_pb2 +from e2b.envd.api import check_sandbox_health +from e2b.envd.rpc import authentication_header, handle_rpc_exception_with_health +from e2b.envd.versions import ENVD_COMMANDS_STDIN, ENVD_ENVD_CLOSE +from e2b.exceptions import SandboxException +from e2b.sandbox.commands.main import ProcessInfo +from e2b.sandbox.commands.command_handle import CommandResult +from e2b.sandbox_sync.commands.command_handle import CommandHandle + + +class Commands: + """ + Module for executing commands in the sandbox. + """ + + def __init__( + self, + envd_api_url: str, + connection_config: ConnectionConfig, + envd_version: Version, + ) -> None: + self._envd_api_url = envd_api_url + self._connection_config = connection_config + self._envd_version = envd_version + self._thread_local = threading.local() + + def _create_envd_api(self) -> httpx.Client: + transport = get_envd_transport(self._connection_config) + return httpx.Client( + base_url=self._envd_api_url, + transport=transport, + headers=self._connection_config.sandbox_headers, + event_hooks=make_logging_event_hooks(self._connection_config.logger), + ) + + def _create_rpc(self) -> process_connect.ProcessClient: + transport = get_envd_transport(self._connection_config) + return process_connect.ProcessClient( + self._envd_api_url, + # TODO: Fix and enable compression again — the headers compression is not solved for streaming. + # compressor=e2b_connect.GzipCompressor, + pool=transport.pool, + json=True, + headers=self._connection_config.sandbox_headers, + logger=self._connection_config.logger, + ) + + @property + def _envd_api(self) -> httpx.Client: + envd_api = getattr(self._thread_local, "envd_api", None) + if envd_api is None: + envd_api = self._create_envd_api() + self._thread_local.envd_api = envd_api + return envd_api + + @property + def _rpc(self) -> process_connect.ProcessClient: + rpc = getattr(self._thread_local, "rpc", None) + if rpc is None: + rpc = self._create_rpc() + self._thread_local.rpc = rpc + return rpc + + def _check_health(self) -> Optional[bool]: + return check_sandbox_health(self._envd_api) + + def list( + self, + request_timeout: Optional[float] = None, + ) -> List[ProcessInfo]: + """ + Lists all running commands and PTY sessions. + + :param request_timeout: Timeout for the request in **seconds** + + :return: List of running commands and PTY sessions + """ + try: + res = self._rpc.list( + process_pb2.ListRequest(), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + return [ + ProcessInfo( + pid=p.pid, + tag=p.tag if p.HasField("tag") else None, + cmd=p.config.cmd, + args=list(p.config.args), + envs=dict(p.config.envs), + cwd=p.config.cwd if p.config.HasField("cwd") else None, + ) + for p in res.processes + ] + except Exception as e: + raise handle_rpc_exception_with_health(e, self._check_health) + + def kill( + self, + pid: int, + request_timeout: Optional[float] = None, + ) -> bool: + """ + Kill a running command specified by its process ID. + It uses `SIGKILL` signal to kill the command. + + :param pid: Process ID of the command. You can get the list of processes using `sandbox.commands.list()` + :param request_timeout: Timeout for the request in **seconds** + + :return: `True` if the command was killed, `False` if the command was not found + """ + try: + self._rpc.send_signal( + process_pb2.SendSignalRequest( + process=process_pb2.ProcessSelector(pid=pid), + signal=process_pb2.Signal.SIGNAL_SIGKILL, + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + return True + except Exception as e: + if isinstance(e, e2b_connect.ConnectException): + if e.status == e2b_connect.Code.not_found: + return False + raise handle_rpc_exception_with_health(e, self._check_health) + + def send_stdin( + self, + pid: int, + data: Union[str, bytes], + request_timeout: Optional[float] = None, + ): + """ + Send data to command stdin. + + :param pid Process ID of the command. You can get the list of processes using `sandbox.commands.list()`. + :param data: Data to send to the command + :param request_timeout: Timeout for the request in **seconds** + """ + try: + self._rpc.send_input( + process_pb2.SendInputRequest( + process=process_pb2.ProcessSelector(pid=pid), + input=process_pb2.ProcessInput( + stdin=data.encode() if isinstance(data, str) else data, + ), + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + except Exception as e: + raise handle_rpc_exception_with_health(e, self._check_health) + + def close_stdin( + self, + pid: int, + request_timeout: Optional[float] = None, + ) -> None: + """ + Close the command stdin. + + This signals EOF to the command. The command must have been started with `stdin=True`. + + :param pid Process ID of the command. You can get the list of processes using `sandbox.commands.list()`. + :param request_timeout: Timeout for the request in **seconds** + """ + if self._envd_version < ENVD_ENVD_CLOSE: + raise SandboxException( + f"Sandbox envd version {self._envd_version} doesn't support closing stdin. " + f"Please rebuild your template to pick up the latest sandbox version." + ) + + try: + self._rpc.close_stdin( + process_pb2.CloseStdinRequest( + process=process_pb2.ProcessSelector(pid=pid), + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + except Exception as e: + raise handle_rpc_exception_with_health(e, self._check_health) + + @overload + def run( + self, + cmd: str, + background: Union[Literal[False], None] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[Username] = None, + cwd: Optional[str] = None, + on_stdout: Optional[Callable[[str], None]] = None, + on_stderr: Optional[Callable[[str], None]] = None, + stdin: Optional[bool] = None, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ) -> CommandResult: + """ + Start a new command and wait until it finishes executing. + + :param cmd: Command to execute + :param background: **`False` if the command should be executed in the foreground**, `True` if the command should be executed in the background + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param on_stdout: Callback for command stdout output + :param on_stderr: Callback for command stderr output + :param stdin: If `True`, the command will have a stdin stream that you can send data to using `sandbox.commands.send_stdin()` + :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time + :param request_timeout: Timeout for the request in **seconds** + + :return: `CommandResult` result of the command execution + """ + ... + + @overload + def run( + self, + cmd: str, + background: Literal[True], + envs: Optional[Dict[str, str]] = None, + user: Optional[Username] = None, + cwd: Optional[str] = None, + on_stdout: None = None, + on_stderr: None = None, + stdin: Optional[bool] = None, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ) -> CommandHandle: + """ + Start a new command and return a handle to interact with it. + + :param cmd: Command to execute + :param background: `False` if the command should be executed in the foreground, **`True` if the command should be executed in the background** + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param stdin: If `True`, the command will have a stdin stream that you can send data to using `sandbox.commands.send_stdin()` + :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time + :param request_timeout: Timeout for the request in **seconds** + + :return: `CommandHandle` handle to interact with the running command + """ + ... + + def run( + self, + cmd: str, + background: Union[bool, None] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[Username] = None, + cwd: Optional[str] = None, + on_stdout: Optional[Callable[[str], None]] = None, + on_stderr: Optional[Callable[[str], None]] = None, + stdin: Optional[bool] = None, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ): + # Check version for stdin support + if stdin is False and self._envd_version < ENVD_COMMANDS_STDIN: + raise SandboxException( + f"Sandbox envd version {self._envd_version} can't specify stdin, it's always turned on. " + f"Please rebuild your template if you need this feature." + ) + + # Default to `False` + stdin = stdin or False + + proc = self._start( + cmd, + envs, + user, + cwd, + stdin, + timeout, + request_timeout, + ) + + return ( + proc + if background + else proc.wait( + on_stdout=on_stdout, + on_stderr=on_stderr, + ) + ) + + def _start( + self, + cmd: str, + envs: Optional[Dict[str, str]], + user: Optional[Username], + cwd: Optional[str], + stdin: bool, + timeout: Optional[float], + request_timeout: Optional[float], + ): + events = self._rpc.start( + process_pb2.StartRequest( + process=process_pb2.ProcessConfig( + cmd="/bin/bash", + envs=envs, + args=["-l", "-c", cmd], + cwd=cwd, + ), + stdin=stdin, + ), + headers={ + **authentication_header(self._envd_version, user), + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + timeout=timeout, + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + + try: + start_event = events.__next__() + + if not start_event.HasField("event"): + raise SandboxException( + f"Failed to start process: expected start event, got {start_event}" + ) + + pid = start_event.event.start.pid + return CommandHandle( + pid=pid, + handle_kill=lambda: self.kill(pid), + events=events, + handle_send_stdin=lambda data, request_timeout=None: self.send_stdin( + pid, data, request_timeout + ), + handle_close_stdin=lambda request_timeout=None: self.close_stdin( + pid, request_timeout + ), + check_health=self._check_health, + ) + except Exception as e: + try: + events.close() + except Exception: + pass + raise handle_rpc_exception_with_health(e, self._check_health) + + def connect( + self, + pid: int, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ): + """ + Connects to a running command. + You can use `CommandHandle.wait()` to wait for the command to finish and get execution results. + + :param pid: Process ID of the command to connect to. You can get the list of processes using `sandbox.commands.list()` + :param timeout: Timeout for the connection in **seconds**. Using `0` will not limit the connection time + :param request_timeout: Timeout for the request in **seconds** + + :return: `CommandHandle` handle to interact with the running command + """ + events = self._rpc.connect( + process_pb2.ConnectRequest( + process=process_pb2.ProcessSelector(pid=pid), + ), + headers={ + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + timeout=timeout, + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + + try: + start_event = events.__next__() + + if not start_event.HasField("event"): + raise SandboxException( + f"Failed to connect to process: expected start event, got {start_event}" + ) + + pid = start_event.event.start.pid + return CommandHandle( + pid=pid, + handle_kill=lambda: self.kill(pid), + events=events, + handle_send_stdin=lambda data, request_timeout=None: self.send_stdin( + pid, data, request_timeout + ), + handle_close_stdin=lambda request_timeout=None: self.close_stdin( + pid, request_timeout + ), + check_health=self._check_health, + ) + except Exception as e: + try: + events.close() + except Exception: + pass + raise handle_rpc_exception_with_health(e, self._check_health) diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/command_handle.py b/packages/python-sdk/e2b/sandbox_sync/commands/command_handle.py new file mode 100644 index 0000000..a9cf5fc --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_sync/commands/command_handle.py @@ -0,0 +1,239 @@ +import codecs + +from typing import Optional, Callable, Any, Generator, List, Union, Tuple + +from e2b.envd.rpc import handle_rpc_exception_with_health +from e2b.envd.process import process_pb2 +from e2b.exceptions import SandboxException +from e2b.sandbox.commands.command_handle import ( + CommandExitException, + CommandResult, + Stderr, + Stdout, + PtyOutput, +) + + +class CommandHandle: + """ + Command execution handle. + + It provides methods for waiting for the command to finish, retrieving stdout/stderr, and killing the command. + """ + + @property + def pid(self): + """ + Command process ID. + """ + return self._pid + + def __init__( + self, + pid: int, + handle_kill: Callable[[], bool], + events: Generator[ + Union[process_pb2.StartResponse, process_pb2.ConnectResponse], Any, None + ], + handle_send_stdin: Optional[ + Callable[[Union[str, bytes], Optional[float]], None] + ] = None, + handle_close_stdin: Optional[Callable[[Optional[float]], None]] = None, + check_health: Optional[Callable[[], Optional[bool]]] = None, + ): + self._pid = pid + self._handle_kill = handle_kill + self._handle_send_stdin = handle_send_stdin + self._handle_close_stdin = handle_close_stdin + self._check_health = check_health + self._events = events + + self._stdout_chunks: List[str] = [] + self._stderr_chunks: List[str] = [] + + self._stdout_decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + self._stderr_decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + + self._result: Optional[CommandResult] = None + self._iteration_exception: Optional[Exception] = None + + def __iter__(self): + """ + Iterate over the command output. + + :return: Generator of command outputs + """ + return self._handle_events() + + def _flush_decoders( + self, + ) -> List[Union[Tuple[Stdout, None, None], Tuple[None, Stderr, None]]]: + """ + Flush any bytes still buffered in the stream decoders. + + Incomplete trailing UTF-8 sequences are emitted as replacement + characters, matching the per-chunk decoding behavior. + """ + events: List[Union[Tuple[Stdout, None, None], Tuple[None, Stderr, None]]] = [] + out = self._stdout_decoder.decode(b"", final=True) + if out: + self._stdout_chunks.append(out) + events.append((out, None, None)) + err = self._stderr_decoder.decode(b"", final=True) + if err: + self._stderr_chunks.append(err) + events.append((None, err, None)) + return events + + def _handle_events( + self, + ) -> Generator[ + Union[ + Tuple[Stdout, None, None], + Tuple[None, Stderr, None], + Tuple[None, None, PtyOutput], + ], + None, + None, + ]: + try: + for event in self._events: + if event.event.HasField("data"): + if event.event.data.stdout: + out = self._stdout_decoder.decode(event.event.data.stdout) + if out: + self._stdout_chunks.append(out) + yield out, None, None + if event.event.data.stderr: + out = self._stderr_decoder.decode(event.event.data.stderr) + if out: + self._stderr_chunks.append(out) + yield None, out, None + if event.event.data.pty: + yield None, None, event.event.data.pty + if event.event.HasField("end"): + # Flush trailing decoder bytes into the accumulators and + # record the result before yielding the flushed chunks, so a + # consumer that stops iterating on the first flushed chunk + # still observes the exit code. + flushed = list(self._flush_decoders()) + self._result = CommandResult( + stdout="".join(self._stdout_chunks), + stderr="".join(self._stderr_chunks), + exit_code=event.event.end.exit_code, + error=event.event.end.error, + ) + yield from flushed + + # If the stream closed without an end event (e.g. disconnect or a + # dropped connection), flush any bytes still buffered in the + # decoders so incomplete trailing sequences surface as replacement + # characters instead of being silently dropped. + if self._result is None: + yield from self._flush_decoders() + except Exception as e: + # The stream raised before an end event (e.g. disconnect or RPC + # failure). Flush any bytes still buffered in the decoders so + # incomplete trailing sequences surface as replacement characters + # instead of being silently dropped, then surface the error. + yield from self._flush_decoders() + raise handle_rpc_exception_with_health(e, self._check_health) + + def disconnect(self) -> None: + """ + Disconnect from the command. + + The command is not killed, but SDK stops receiving events from the command. + You can reconnect to the command using `sandbox.commands.connect` method. + """ + self._events.close() + + def wait( + self, + on_pty: Optional[Callable[[PtyOutput], None]] = None, + on_stdout: Optional[Callable[[str], None]] = None, + on_stderr: Optional[Callable[[str], None]] = None, + ) -> CommandResult: + """ + Wait for the command to finish and returns the result. + If the command exits with a non-zero exit code, it throws a `CommandExitException`. + + :param on_pty: Callback for pty output + :param on_stdout: Callback for stdout output + :param on_stderr: Callback for stderr output + + :return: `CommandResult` result of command execution + """ + try: + for stdout, stderr, pty in self: + if stdout is not None and on_stdout: + on_stdout(stdout) + elif stderr is not None and on_stderr: + on_stderr(stderr) + elif pty is not None and on_pty: + on_pty(pty) + except StopIteration: + pass + except Exception as e: + self._iteration_exception = handle_rpc_exception_with_health( + e, self._check_health + ) + + if self._iteration_exception: + raise self._iteration_exception + + if self._result is None: + raise Exception("Command ended without an end event") + + if self._result.exit_code != 0: + raise CommandExitException( + stdout="".join(self._stdout_chunks), + stderr="".join(self._stderr_chunks), + exit_code=self._result.exit_code, + error=self._result.error, + ) + + return self._result + + def kill(self) -> bool: + """ + Kills the command. + + It uses `SIGKILL` signal to kill the command. + + :return: Whether the command was killed successfully + """ + return self._handle_kill() + + def send_stdin( + self, + data: Union[str, bytes], + request_timeout: Optional[float] = None, + ) -> None: + """ + Send data to the command stdin. + + The command must have been started with `stdin=True`. + + :param data: Data to send to the command + :param request_timeout: Timeout for the request in **seconds** + """ + if self._handle_send_stdin is None: + raise SandboxException( + "Sending stdin is not supported for this command handle." + ) + self._handle_send_stdin(data, request_timeout) + + def close_stdin(self, request_timeout: Optional[float] = None) -> None: + """ + Close the command stdin. + + This signals EOF to the command. The command must have been started with `stdin=True`. + + :param request_timeout: Timeout for the request in **seconds** + """ + if self._handle_close_stdin is None: + raise SandboxException( + "Closing stdin is not supported for this command handle." + ) + self._handle_close_stdin(request_timeout) diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/pty.py b/packages/python-sdk/e2b/sandbox_sync/commands/pty.py new file mode 100644 index 0000000..f0de8c0 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_sync/commands/pty.py @@ -0,0 +1,279 @@ +import e2b_connect +import httpx +import threading + +from typing import Dict, Optional + +from packaging.version import Version +from e2b.api import make_logging_event_hooks +from e2b.api.client_sync import get_envd_transport +from e2b.envd.process import process_connect, process_pb2 +from e2b.connection_config import ( + Username, + ConnectionConfig, + KEEPALIVE_PING_HEADER, + KEEPALIVE_PING_INTERVAL_SEC, +) +from e2b.exceptions import SandboxException +from e2b.envd.api import check_sandbox_health +from e2b.envd.rpc import authentication_header, handle_rpc_exception_with_health +from e2b.sandbox.commands.command_handle import PtySize +from e2b.sandbox_sync.commands.command_handle import CommandHandle + + +class Pty: + """ + Module for interacting with PTYs (pseudo-terminals) in the sandbox. + """ + + def __init__( + self, + envd_api_url: str, + connection_config: ConnectionConfig, + envd_version: Version, + ) -> None: + self._envd_api_url = envd_api_url + self._connection_config = connection_config + self._envd_version = envd_version + self._thread_local = threading.local() + + def _create_envd_api(self) -> httpx.Client: + transport = get_envd_transport(self._connection_config) + return httpx.Client( + base_url=self._envd_api_url, + transport=transport, + headers=self._connection_config.sandbox_headers, + event_hooks=make_logging_event_hooks(self._connection_config.logger), + ) + + def _create_rpc(self) -> process_connect.ProcessClient: + transport = get_envd_transport(self._connection_config) + return process_connect.ProcessClient( + self._envd_api_url, + # TODO: Fix and enable compression again — the headers compression is not solved for streaming. + # compressor=e2b_connect.GzipCompressor, + pool=transport.pool, + json=True, + headers=self._connection_config.sandbox_headers, + logger=self._connection_config.logger, + ) + + @property + def _envd_api(self) -> httpx.Client: + envd_api = getattr(self._thread_local, "envd_api", None) + if envd_api is None: + envd_api = self._create_envd_api() + self._thread_local.envd_api = envd_api + return envd_api + + @property + def _rpc(self) -> process_connect.ProcessClient: + rpc = getattr(self._thread_local, "rpc", None) + if rpc is None: + rpc = self._create_rpc() + self._thread_local.rpc = rpc + return rpc + + def _check_health(self) -> Optional[bool]: + return check_sandbox_health(self._envd_api) + + def kill( + self, + pid: int, + request_timeout: Optional[float] = None, + ) -> bool: + """ + Kill PTY. + + :param pid: Process ID of the PTY + :param request_timeout: Timeout for the request in **seconds** + + :return: `true` if the PTY was killed, `false` if the PTY was not found + """ + try: + self._rpc.send_signal( + process_pb2.SendSignalRequest( + process=process_pb2.ProcessSelector(pid=pid), + signal=process_pb2.Signal.SIGNAL_SIGKILL, + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + return True + except Exception as e: + if isinstance(e, e2b_connect.ConnectException): + if e.status == e2b_connect.Code.not_found: + return False + raise handle_rpc_exception_with_health(e, self._check_health) + + def send_stdin( + self, + pid: int, + data: bytes, + request_timeout: Optional[float] = None, + ) -> None: + """ + Send input to a PTY. + + :param pid: Process ID of the PTY + :param data: Input data to send + :param request_timeout: Timeout for the request in **seconds** + """ + try: + self._rpc.send_input( + process_pb2.SendInputRequest( + process=process_pb2.ProcessSelector(pid=pid), + input=process_pb2.ProcessInput( + pty=data, + ), + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + except Exception as e: + raise handle_rpc_exception_with_health(e, self._check_health) + + def create( + self, + size: PtySize, + user: Optional[Username] = None, + cwd: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ) -> CommandHandle: + """ + Start a new PTY (pseudo-terminal). + + :param size: Size of the PTY + :param user: User to use for the PTY + :param cwd: Working directory for the PTY + :param envs: Environment variables for the PTY + :param timeout: Timeout for the PTY in **seconds** + :param request_timeout: Timeout for the request in **seconds** + + :return: Handle to interact with the PTY + """ + envs = dict(envs) if envs else {} + envs.setdefault("TERM", "xterm-256color") + envs.setdefault("LANG", "C.UTF-8") + envs.setdefault("LC_ALL", "C.UTF-8") + events = self._rpc.start( + process_pb2.StartRequest( + process=process_pb2.ProcessConfig( + cmd="/bin/bash", + envs=envs, + args=["-i", "-l"], + cwd=cwd, + ), + pty=process_pb2.PTY( + size=process_pb2.PTY.Size(rows=size.rows, cols=size.cols) + ), + ), + headers={ + **authentication_header(self._envd_version, user), + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + timeout=timeout, + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + + try: + start_event = events.__next__() + + if not start_event.HasField("event"): + raise SandboxException( + f"Failed to start process: expected start event, got {start_event}" + ) + + return CommandHandle( + pid=start_event.event.start.pid, + handle_kill=lambda: self.kill(start_event.event.start.pid), + events=events, + check_health=self._check_health, + ) + except Exception as e: + try: + events.close() + except Exception: + pass + raise handle_rpc_exception_with_health(e, self._check_health) + + def connect( + self, + pid: int, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ) -> CommandHandle: + """ + Connect to a running PTY. + + :param pid: Process ID of the PTY to connect to. You can get the list of running PTYs using `sandbox.pty.list()`. + :param timeout: Timeout for the PTY connection in **seconds**. Using `0` will not limit the connection time + :param request_timeout: Timeout for the request in **seconds** + + :return: Handle to interact with the PTY + """ + events = self._rpc.connect( + process_pb2.ConnectRequest( + process=process_pb2.ProcessSelector(pid=pid), + ), + headers={ + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + timeout=timeout, + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + + try: + start_event = events.__next__() + + if not start_event.HasField("event"): + raise SandboxException( + f"Failed to connect to process: expected start event, got {start_event}" + ) + + return CommandHandle( + pid=start_event.event.start.pid, + handle_kill=lambda: self.kill(start_event.event.start.pid), + events=events, + check_health=self._check_health, + ) + except Exception as e: + try: + events.close() + except Exception: + pass + raise handle_rpc_exception_with_health(e, self._check_health) + + def resize( + self, + pid: int, + size: PtySize, + request_timeout: Optional[float] = None, + ) -> None: + """ + Resize PTY. + Call this when the terminal window is resized and the number of columns and rows has changed. + + :param pid: Process ID of the PTY + :param size: New size of the PTY + :param request_timeout: Timeout for the request in **seconds** + """ + self._rpc.update( + process_pb2.UpdateRequest( + process=process_pb2.ProcessSelector(pid=pid), + pty=process_pb2.PTY( + size=process_pb2.PTY.Size(rows=size.rows, cols=size.cols), + ), + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py new file mode 100644 index 0000000..0b9028a --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py @@ -0,0 +1,710 @@ +import threading +from typing import IO, Dict, List, Literal, Optional, Union, overload + +import httpx +from packaging.version import Version + +import e2b_connect +from e2b.api import make_logging_event_hooks +from e2b.api.client_sync import get_envd_transport +from e2b.connection_config import ( + KEEPALIVE_PING_HEADER, + KEEPALIVE_PING_INTERVAL_SEC, + ConnectionConfig, + Username, + default_username, +) +from e2b_connect.client import Code + +from e2b.envd.api import ( + ENVD_API_FILES_ROUTE, + check_sandbox_health, + handle_envd_api_exception, + handle_envd_api_transport_exception_with_health, +) +from e2b.envd.filesystem import filesystem_connect, filesystem_pb2 +from e2b.envd.rpc import authentication_header, handle_rpc_exception_with_health +from e2b.envd.versions import ( + ENVD_DEFAULT_USER, + ENVD_FILE_METADATA, + ENVD_OCTET_STREAM_UPLOAD, + ENVD_VERSION_FS_EVENT_ENTRY_INFO, + ENVD_VERSION_RECURSIVE_WATCH, + ENVD_VERSION_WATCH_NETWORK_MOUNTS, +) +from e2b.exceptions import ( + FileNotFoundException, + InvalidArgumentException, + SandboxException, + TemplateException, +) +from e2b.sandbox.filesystem.filesystem import ( + EntryInfo, + FileStreamReader, + WriteEntry, + WriteInfo, + _to_httpx_file, + map_entry_info, + map_file_type, + metadata_to_headers, + to_upload_body, + validate_metadata, +) +from e2b.sandbox_sync.filesystem.watch_handle import WatchHandle + + +_FILESYSTEM_RPC_ERROR_MAP = { + Code.not_found: FileNotFoundException, +} + +_FILESYSTEM_HTTP_ERROR_MAP = { + 404: FileNotFoundException, +} + + +def _handle_filesystem_rpc_exception(e: Exception, envd_api: httpx.Client) -> Exception: + return handle_rpc_exception_with_health( + e, lambda: check_sandbox_health(envd_api), _FILESYSTEM_RPC_ERROR_MAP + ) + + +def _handle_filesystem_envd_api_exception(r): + return handle_envd_api_exception(r, _FILESYSTEM_HTTP_ERROR_MAP) + + +class Filesystem: + """ + Module for interacting with the filesystem in the sandbox. + """ + + def __init__( + self, + envd_api_url: str, + envd_version: Version, + connection_config: ConnectionConfig, + ) -> None: + self._envd_api_url = envd_api_url + self._envd_version = envd_version + self._connection_config = connection_config + self._thread_local = threading.local() + + def _create_envd_api(self) -> httpx.Client: + transport = get_envd_transport(self._connection_config) + return httpx.Client( + base_url=self._envd_api_url, + transport=transport, + headers=self._connection_config.sandbox_headers, + event_hooks=make_logging_event_hooks(self._connection_config.logger), + ) + + def _create_rpc(self) -> filesystem_connect.FilesystemClient: + transport = get_envd_transport(self._connection_config) + return filesystem_connect.FilesystemClient( + self._envd_api_url, + # TODO: Fix and enable compression again — the headers compression is not solved for streaming. + # compressor=e2b_connect.GzipCompressor, + pool=transport.pool, + json=True, + headers=self._connection_config.sandbox_headers, + logger=self._connection_config.logger, + ) + + @property + def _envd_api(self) -> httpx.Client: + envd_api = getattr(self._thread_local, "envd_api", None) + if envd_api is None: + envd_api = self._create_envd_api() + self._thread_local.envd_api = envd_api + return envd_api + + @property + def _rpc(self) -> filesystem_connect.FilesystemClient: + rpc = getattr(self._thread_local, "rpc", None) + if rpc is None: + rpc = self._create_rpc() + self._thread_local.rpc = rpc + return rpc + + @overload + def read( + self, + path: str, + format: Literal["text"] = "text", + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + gzip: bool = False, + ) -> str: + """ + Read file content as a `str`. + + :param path: Path to the file + :param user: Run the operation as this user + :param format: Format of the file content—`text` by default + :param request_timeout: Timeout for the request in **seconds** + :param gzip: Use gzip compression for the request + + :return: File content as a `str` + """ + ... + + @overload + def read( + self, + path: str, + format: Literal["bytes"], + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + gzip: bool = False, + ) -> bytearray: + """ + Read file content as a `bytearray`. + + :param path: Path to the file + :param user: Run the operation as this user + :param format: Format of the file content—`bytes` + :param request_timeout: Timeout for the request in **seconds** + :param gzip: Use gzip compression for the request + + :return: File content as a `bytearray` + """ + ... + + @overload + def read( + self, + path: str, + format: Literal["stream"], + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + gzip: bool = False, + stream_idle_timeout: Optional[float] = None, + ) -> FileStreamReader: + """ + Read file content as a `FileStreamReader` (an `Iterator[bytes]`). + + The request timeout bounds only the initial handshake—the returned + iterator is not killed by it while being consumed. A stalled stream is + reclaimed by `stream_idle_timeout` (raising `httpx.ReadTimeout`). The + reader releases its connection once fully consumed; if you don't read it + to the end, use it as a context manager or call `close()` for + deterministic cleanup. + + :param path: Path to the file + :param user: Run the operation as this user + :param format: Format of the file content—`stream` + :param request_timeout: Timeout for the request in **seconds** + :param gzip: Use gzip compression for the request + :param stream_idle_timeout: Idle timeout in **seconds** for the streamed + body—abort if no chunk arrives within this window. Resets on every + chunk, so it bounds a stalled stream without limiting total transfer + time. Defaults to the request timeout; pass `0` to disable. + + :return: File content as a `FileStreamReader` + """ + ... + + def read( + self, + path: str, + format: Literal["text", "bytes", "stream"] = "text", + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + gzip: bool = False, + stream_idle_timeout: Optional[float] = None, + ): + username = user + if username is None and self._envd_version < ENVD_DEFAULT_USER: + username = default_username + + params = {"path": path} + if username: + params["username"] = username + + headers = {} + if gzip: + headers["Accept-Encoding"] = "gzip" + + timeout = self._connection_config.get_request_timeout(request_timeout) + + if format == "stream": + # Stream the response body instead of buffering it in memory. + request = self._envd_api.build_request( + "GET", + ENVD_API_FILES_ROUTE, + params=params, + headers=headers, + timeout=timeout, + ) + try: + r = self._envd_api.send(request, stream=True) + except httpx.RemoteProtocolError as e: + raise handle_envd_api_transport_exception_with_health(e, self._envd_api) + + err = _handle_filesystem_envd_api_exception(r) + if err: + r.close() + raise err + + # The request timeout bounds only the initial handshake; httpx's + # per-chunk `read` timeout becomes the idle-read timeout for the body + # (defaults to the request timeout). The timeout dict is shared by + # reference with the transport and read again when iteration starts. + idle_timeout = ( + timeout if stream_idle_timeout is None else stream_idle_timeout + ) + request.extensions.get("timeout", {})["read"] = idle_timeout or None + + return FileStreamReader(r) + + try: + r = self._envd_api.get( + ENVD_API_FILES_ROUTE, + params=params, + headers=headers, + timeout=timeout, + ) + except httpx.RemoteProtocolError as e: + raise handle_envd_api_transport_exception_with_health(e, self._envd_api) + + err = _handle_filesystem_envd_api_exception(r) + if err: + raise err + + if format == "text": + return r.text + elif format == "bytes": + return bytearray(r.content) + + def write( + self, + path: str, + data: Union[str, bytes, IO], + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + gzip: bool = False, + use_octet_stream: Optional[bool] = None, + metadata: Optional[Dict[str, str]] = None, + ) -> WriteInfo: + """ + Write content to a file on the path. + Writing to a file that doesn't exist creates the file. + Writing to a file that already exists overwrites the file. + Writing to a file at path that doesn't exist creates the necessary directories. + + :param path: Path to the file + :param data: Data to write to the file, can be a `str`, `bytes`, or `IO`. File-like objects are streamed in chunks instead of being buffered in memory. + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + :param gzip: Use gzip compression for the upload. Implies the `application/octet-stream` upload. Requires envd 0.5.7 or later — when not supported, the upload falls back to uncompressed `multipart/form-data`. + :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `None`, which uses octet-stream when `data` is a file-like object (so streamed uploads aren't buffered) and `multipart/form-data` otherwise. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. + :param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Keys are lowercased by the sandbox; invalid keys or values raise an `InvalidArgumentException`. Requires envd 0.6.2 or later. + + :return: Information about the written file + """ + result = self.write_files( + [WriteEntry(path=path, data=data)], + user=user, + request_timeout=request_timeout, + gzip=gzip, + use_octet_stream=use_octet_stream, + metadata=metadata, + ) + + if len(result) != 1: + raise SandboxException("Received unexpected response from write operation") + + return result[0] + + def write_files( + self, + files: List[WriteEntry], + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + gzip: bool = False, + use_octet_stream: Optional[bool] = None, + metadata: Optional[Dict[str, str]] = None, + ) -> List[WriteInfo]: + """ + Writes multiple files. + + Writes a list of files to the filesystem. + When writing to a file that doesn't exist, the file will get created. + When writing to a file that already exists, the file will get overwritten. + When writing to a file at path that doesn't exist, the necessary directories will be created. + + :param files: list of files to write as `WriteEntry` objects, each containing `path` and `data` + :param user: Run the operation as this user + :param request_timeout: Timeout for the request + :param gzip: Use gzip compression for the upload. Implies the `application/octet-stream` upload. Requires envd 0.5.7 or later — when not supported, the upload falls back to uncompressed `multipart/form-data`. + :param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `None`, which uses octet-stream when any entry is a file-like object (so streamed uploads aren't buffered) and `multipart/form-data` otherwise. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`. + :param metadata: User-defined metadata to persist on each uploaded file as extended attributes; the same map is applied to every file. Keys are lowercased by the sandbox; invalid keys or values raise an `InvalidArgumentException`. Requires envd 0.6.2 or later. + :return: Information about the written files + """ + username = user + if username is None and self._envd_version < ENVD_DEFAULT_USER: + username = default_username + + if len(files) == 0: + return [] + + validate_metadata(metadata) + + if metadata and self._envd_version < ENVD_FILE_METADATA: + raise TemplateException("File metadata requires envd 0.6.2 or later.") + + # A file-like entry is streamed; str/bytes are sent from memory. + has_streamable_data = any( + not isinstance(file["data"], (str, bytes)) for file in files + ) + + if use_octet_stream is None: + # Streaming an upload only happens on the octet-stream path; the + # multipart path buffers file-like data. Default to octet-stream + # when any entry is a file-like object so a streamed upload isn't + # silently buffered. + use_octet_stream = has_streamable_data + + supports_octet_stream = self._envd_version >= ENVD_OCTET_STREAM_UPLOAD + # Gzip compression only works with the octet-stream upload (the + # Content-Encoding header applies to the whole request body), so + # requesting gzip implies it when envd supports it. + use_octet_stream = (use_octet_stream or gzip) and supports_octet_stream + + # Each chunk send is bounded by the request timeout (httpx applies it + # per write); a stalled upload the per-write timeout can't observe is + # bounded server-side (envd's per-read idle timeout, envd >= 0.6.7). + upload_timeout = self._connection_config.get_request_timeout(request_timeout) + + # Metadata is sent as request-scoped X-Metadata-* headers, so the same + # metadata is applied to every file in a multi-file upload. + extra_headers = metadata_to_headers(metadata) + + results: List[WriteInfo] = [] + + if use_octet_stream: + for file in files: + file_path, file_data = file["path"], file["data"] + + params = {"path": file_path} + if username: + params["username"] = username + + headers = {"Content-Type": "application/octet-stream", **extra_headers} + if gzip: + headers["Content-Encoding"] = "gzip" + + try: + r = self._envd_api.post( + ENVD_API_FILES_ROUTE, + content=to_upload_body(file_data, gzip), + headers=headers, + params=params, + timeout=upload_timeout, + ) + except httpx.RemoteProtocolError as e: + raise handle_envd_api_transport_exception_with_health( + e, self._envd_api + ) + + err = _handle_filesystem_envd_api_exception(r) + if err: + raise err + + write_result = r.json() + + if not isinstance(write_result, list) or len(write_result) == 0: + raise SandboxException( + "Expected to receive information about written file" + ) + + results.extend([WriteInfo.from_dict(f) for f in write_result]) + else: + params = {} + if username: + params["username"] = username + if len(files) == 1: + params["path"] = files[0]["path"] + + httpx_files = [_to_httpx_file(file["path"], file["data"]) for file in files] + + if len(httpx_files) == 0: + return [] + + try: + r = self._envd_api.post( + ENVD_API_FILES_ROUTE, + files=httpx_files, + params=params, + headers=extra_headers, + timeout=upload_timeout, + ) + except httpx.RemoteProtocolError as e: + raise handle_envd_api_transport_exception_with_health(e, self._envd_api) + + err = _handle_filesystem_envd_api_exception(r) + if err: + raise err + + write_result = r.json() + + if not isinstance(write_result, list) or len(write_result) == 0: + raise SandboxException( + "Expected to receive information about written file" + ) + + results.extend([WriteInfo.from_dict(f) for f in write_result]) + + return results + + def list( + self, + path: str, + depth: Optional[int] = 1, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> List[EntryInfo]: + """ + List entries in a directory. + + :param path: Path to the directory + :param depth: Depth of the directory to list + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: List of entries in the directory + """ + if depth is not None and depth < 1: + raise InvalidArgumentException("depth should be at least 1") + + try: + res = self._rpc.list_dir( + filesystem_pb2.ListDirRequest(path=path, depth=depth), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + entries: List[EntryInfo] = [] + for entry in res.entries: + # Skip entries with an unknown file type. + if map_file_type(entry.type): + entries.append(map_entry_info(entry)) + + return entries + except Exception as e: + raise _handle_filesystem_rpc_exception(e, self._envd_api) + + def exists( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> bool: + """ + Check if a file or a directory exists. + + :param path: Path to a file or a directory + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: `True` if the file or directory exists, `False` otherwise + """ + try: + self._rpc.stat( + filesystem_pb2.StatRequest(path=path), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + return True + + except Exception as e: + if isinstance(e, e2b_connect.ConnectException): + if e.status == e2b_connect.Code.not_found: + return False + raise _handle_filesystem_rpc_exception(e, self._envd_api) + + def get_info( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> EntryInfo: + """ + Get information about a file or directory. + + :param path: Path to a file or a directory + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: Information about the file or directory like name, type, and path + """ + try: + r = self._rpc.stat( + filesystem_pb2.StatRequest(path=path), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + return map_entry_info(r.entry) + except Exception as e: + raise _handle_filesystem_rpc_exception(e, self._envd_api) + + def remove( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> None: + """ + Remove a file or a directory. + + :param path: Path to a file or a directory + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + """ + try: + self._rpc.remove( + filesystem_pb2.RemoveRequest(path=path), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + except Exception as e: + raise _handle_filesystem_rpc_exception(e, self._envd_api) + + def rename( + self, + old_path: str, + new_path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> EntryInfo: + """ + Rename a file or directory. + + :param old_path: Path to the file or directory to rename + :param new_path: New path to the file or directory + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: Information about the renamed file or directory + """ + try: + r = self._rpc.move( + filesystem_pb2.MoveRequest( + source=old_path, + destination=new_path, + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + return map_entry_info(r.entry) + except Exception as e: + raise _handle_filesystem_rpc_exception(e, self._envd_api) + + def make_dir( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> bool: + """ + Create a new directory and all directories along the way if needed on the specified path. + + :param path: Path to a new directory. For example '/dirA/dirB' when creating 'dirB'. + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: `True` if the directory was created, `False` if the directory already exists + """ + try: + self._rpc.make_dir( + filesystem_pb2.MakeDirRequest(path=path), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + return True + except Exception as e: + if isinstance(e, e2b_connect.ConnectException): + if e.status == e2b_connect.Code.already_exists: + return False + raise _handle_filesystem_rpc_exception(e, self._envd_api) + + def watch_dir( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + recursive: bool = False, + include_entry: bool = False, + allow_network_mounts: bool = False, + ) -> WatchHandle: + """ + Watch directory for filesystem events. + + :param path: Path to a directory to watch + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + :param recursive: Watch directory recursively + :param include_entry: Include the `EntryInfo` of the affected entry in each event, when available. Requires envd 0.6.3 or later + :param allow_network_mounts: Allow watching paths on network filesystem mounts (NFS, CIFS, SMB, FUSE), which are rejected by default. Events on network mounts may be unreliable or not delivered at all. Requires envd 0.6.4 or later + + :return: `WatchHandle` object for stopping watching directory + """ + if recursive and self._envd_version < ENVD_VERSION_RECURSIVE_WATCH: + raise TemplateException( + "You need to update the template to use recursive watching." + ) + + if include_entry and self._envd_version < ENVD_VERSION_FS_EVENT_ENTRY_INFO: + raise TemplateException( + "You need to update the template to include entry info in watch events." + ) + + if ( + allow_network_mounts + and self._envd_version < ENVD_VERSION_WATCH_NETWORK_MOUNTS + ): + raise TemplateException( + "You need to update the template to watch directories on network mounts." + ) + + try: + r = self._rpc.create_watcher( + filesystem_pb2.CreateWatcherRequest( + path=path, + recursive=recursive, + include_entry=include_entry, + allow_network_mounts=allow_network_mounts, + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers={ + **authentication_header(self._envd_version, user), + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + ) + except Exception as e: + raise _handle_filesystem_rpc_exception(e, self._envd_api) + + return WatchHandle( + lambda: self._rpc, + r.watcher_id, + self._connection_config, + self._envd_version, + user, + lambda: check_sandbox_health(self._envd_api), + ) diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/watch_handle.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/watch_handle.py new file mode 100644 index 0000000..7d14b25 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/watch_handle.py @@ -0,0 +1,102 @@ +from typing import Callable, List, Optional + +from packaging.version import Version + +from e2b import SandboxException +from e2b.connection_config import ConnectionConfig, Username +from e2b.envd.filesystem import filesystem_connect +from e2b.envd.filesystem.filesystem_pb2 import ( + GetWatcherEventsRequest, + RemoveWatcherRequest, +) +from e2b.envd.rpc import authentication_header, handle_rpc_exception_with_health +from e2b.sandbox.filesystem.filesystem import map_entry_info +from e2b.sandbox.filesystem.watch_handle import FilesystemEvent, map_event_type + + +class WatchHandle: + """ + Handle for watching filesystem events. + It is used to get the latest events that have occurred in the watched directory. + + Use `.stop()` to stop watching the directory. + """ + + def __init__( + self, + get_rpc: Callable[[], filesystem_connect.FilesystemClient], + watcher_id: str, + connection_config: ConnectionConfig, + envd_version: Version, + user: Optional[Username] = None, + check_health: Optional[Callable[[], Optional[bool]]] = None, + ): + self._get_rpc = get_rpc + self._watcher_id = watcher_id + self._connection_config = connection_config + self._envd_version = envd_version + self._user = user + self._check_health = check_health + self._closed = False + + def stop(self, request_timeout: Optional[float] = None): + """ + Stop watching the directory. + After you stop the watcher you won't be able to get the events anymore. + + :param request_timeout: Timeout for the request in **seconds** + """ + try: + self._get_rpc().remove_watcher( + RemoveWatcherRequest(watcher_id=self._watcher_id), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, self._user), + ) + except Exception as e: + raise handle_rpc_exception_with_health(e, self._check_health) + + self._closed = True + + def get_new_events( + self, request_timeout: Optional[float] = None + ) -> List[FilesystemEvent]: + """ + Get the latest events that have occurred in the watched directory since the last call, or from the beginning of the watching, up until now. + + :param request_timeout: Timeout for the request in **seconds** + + :return: List of filesystem events + """ + if self._closed: + raise SandboxException("The watcher is already stopped") + + try: + r = self._get_rpc().get_watcher_events( + GetWatcherEventsRequest(watcher_id=self._watcher_id), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, self._user), + ) + except Exception as e: + raise handle_rpc_exception_with_health(e, self._check_health) + + events = [] + for event in r.events: + event_type = map_event_type(event.type) + if event_type: + events.append( + FilesystemEvent( + name=event.name, + type=event_type, + entry=( + map_entry_info(event.entry) + if event.HasField("entry") + else None + ), + ) + ) + + return events diff --git a/packages/python-sdk/e2b/sandbox_sync/git.py b/packages/python-sdk/e2b/sandbox_sync/git.py new file mode 100644 index 0000000..39edb5c --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_sync/git.py @@ -0,0 +1,1077 @@ +from typing import Dict, List, Optional + +from e2b.sandbox._git import ( + GitBranches, + GitResetMode, + GitStatus, + build_add_args, + build_auth_error_message, + build_branches_args, + build_checkout_branch_args, + build_clone_plan, + build_commit_args, + build_credential_approve_command, + build_create_branch_args, + build_delete_branch_args, + build_git_command, + build_has_upstream_args, + build_pull_args, + build_push_args, + build_remote_add_args, + build_remote_add_shell_command, + build_remote_get_command, + build_remote_get_url_args, + build_remote_set_url_args, + build_reset_args, + build_restore_args, + build_status_args, + build_upstream_error_message, + is_auth_failure, + is_missing_upstream, + parse_git_branches, + parse_git_status, + parse_remote_url, + resolve_config_scope, + with_credentials, +) +from e2b.exceptions import ( + GitAuthException, + GitUpstreamException, + InvalidArgumentException, +) +from e2b.sandbox.commands.command_handle import CommandExitException +from e2b.sandbox_sync.commands.command import Commands + + +DEFAULT_GIT_ENV = {"GIT_TERMINAL_PROMPT": "0"} + + +class Git: + """ + Module for running git operations in the sandbox. + """ + + def __init__(self, commands: Commands) -> None: + """ + Create a Git helper bound to the sandbox command runner. + + :param commands: Command runner used to execute git commands + """ + self._commands = commands + + def _run_git( + self, + args: List[str], + repo_path: Optional[str], + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Build and execute a git command inside the sandbox. + + :param args: Git arguments to pass to the git binary + :param repo_path: Repository path used with `git -C`, if provided + :param envs: Extra environment variables for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + cmd = build_git_command(args, repo_path) + merged_envs = {**DEFAULT_GIT_ENV, **(envs or {})} + return self._commands.run( + cmd, + envs=merged_envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) + + def _run_shell( + self, + cmd: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Execute a raw shell command while applying default git environment variables. + + :param cmd: Shell command to execute + :param envs: Extra environment variables for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + merged_envs = {**DEFAULT_GIT_ENV, **(envs or {})} + return self._commands.run( + cmd, + envs=merged_envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) + + def _has_upstream( + self, + path: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> bool: + try: + result = self._run_git( + build_has_upstream_args(), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return bool(result.stdout.strip()) + except Exception: + return False + + def _get_remote_url( + self, + path: str, + remote: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> str: + result = self._run_git( + build_remote_get_url_args(remote), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return parse_remote_url(result.stdout, remote) + + def _resolve_remote_name( + self, + path: str, + remote: Optional[str], + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> str: + if remote: + return remote + + result = self._run_git( + ["remote"], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + remotes = [line.strip() for line in result.stdout.splitlines() if line.strip()] + if len(remotes) == 1: + return remotes[0] + + raise InvalidArgumentException( + "Remote is required when using username/password and the repository has multiple remotes." + ) + + def _with_remote_credentials( + self, + path: str, + remote: str, + username: str, + password: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + operation=None, + ): + original_url = self._get_remote_url( + path, remote, envs, user, cwd, timeout, request_timeout + ) + credential_url = with_credentials(original_url, username, password) + self._run_git( + ["remote", "set-url", remote, credential_url], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + result = None + operation_error: Exception | None = None + try: + if operation is None: + raise InvalidArgumentException("Operation is required.") + result = operation() + except Exception as err: + operation_error = err + + restore_error: Exception | None = None + try: + self._run_git( + ["remote", "set-url", remote, original_url], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + except Exception as err: + restore_error = err + + if operation_error: + raise operation_error + if restore_error: + raise restore_error + + return result + + def clone( + self, + url: str, + path: Optional[str] = None, + branch: Optional[str] = None, + depth: Optional[int] = None, + username: Optional[str] = None, + password: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + dangerously_store_credentials: bool = False, + ): + """ + Clone a git repository into the sandbox. + + :param url: Git repository URL + :param path: Destination path for the clone + :param branch: Branch to check out + :param depth: If set, perform a shallow clone with this depth + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :param dangerously_store_credentials: Store credentials in the cloned repository when True + :return: Command result from the command runner + """ + if password and not username: + raise InvalidArgumentException( + "Username is required when using a password or token for git clone." + ) + + def attempt_clone(auth_username: Optional[str], auth_password: Optional[str]): + plan = build_clone_plan( + url=url, + path=path, + branch=branch, + depth=depth, + auth_username=auth_username, + auth_password=auth_password, + dangerously_store_credentials=dangerously_store_credentials, + ) + result = self._run_git( + plan.args, None, envs, user, cwd, timeout, request_timeout + ) + if plan.should_strip and plan.repo_path and plan.sanitized_url: + self._run_git( + build_remote_set_url_args("origin", plan.sanitized_url), + plan.repo_path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return result + + try: + return attempt_clone(username, password) + except CommandExitException as err: + if is_auth_failure(err): + raise GitAuthException( + build_auth_error_message("clone", bool(username) and not password) + ) from err + raise + + def init( + self, + path: str, + bare: bool = False, + initial_branch: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Initialize a new git repository. + + :param path: Destination path for the repository + :param bare: Create a bare repository when True + :param initial_branch: Initial branch name (for example, "main") + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = ["init"] + if initial_branch: + args.extend(["--initial-branch", initial_branch]) + if bare: + args.append("--bare") + args.append(path) + return self._run_git(args, None, envs, user, cwd, timeout, request_timeout) + + def remote_add( + self, + path: str, + name: str, + url: str, + fetch: bool = False, + overwrite: bool = False, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Add (or update) a remote for a repository. + + :param path: Repository path + :param name: Remote name (for example, "origin") + :param url: Remote URL + :param fetch: Fetch the remote after adding it when True + :param overwrite: Overwrite the remote URL if it already exists when True + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = build_remote_add_args(name, url, fetch) + + if not overwrite: + return self._run_git(args, path, envs, user, cwd, timeout, request_timeout) + + cmd = build_remote_add_shell_command(args, path, name, url, fetch) + return self._run_shell( + cmd, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + def remote_get( + self, + path: str, + name: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> Optional[str]: + """ + Get the URL for a git remote. + + Returns `None` when the remote does not exist. + + :param path: Repository path + :param name: Remote name (for example, "origin") + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Remote URL if present, otherwise `None` + """ + cmd = build_remote_get_command(path, name) + result = self._run_shell( + cmd, + envs, + user, + cwd, + timeout, + request_timeout, + ).stdout.strip() + return result or None + + def status( + self, + path: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> GitStatus: + """ + Get repository status information. + + :param path: Repository path + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Parsed git status + """ + result = self._run_git( + build_status_args(), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return parse_git_status(result.stdout) + + def branches( + self, + path: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> GitBranches: + """ + List branches in a repository. + + :param path: Repository path + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Parsed branch list + """ + result = self._run_git( + build_branches_args(), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return parse_git_branches(result.stdout) + + def create_branch( + self, + path: str, + branch: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Create and check out a new branch. + + :param path: Repository path + :param branch: Branch name to create + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + return self._run_git( + build_create_branch_args(branch), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + def checkout_branch( + self, + path: str, + branch: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Check out an existing branch. + + :param path: Repository path + :param branch: Branch name to check out + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + return self._run_git( + build_checkout_branch_args(branch), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + def delete_branch( + self, + path: str, + branch: str, + force: bool = False, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Delete a branch. + + :param path: Repository path + :param branch: Branch name to delete + :param force: Force deletion with `-D` when `True` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = build_delete_branch_args(branch, force) + return self._run_git(args, path, envs, user, cwd, timeout, request_timeout) + + def add( + self, + path: str, + files: Optional[List[str]] = None, + all: bool = True, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Stage files for commit. + + :param path: Repository path + :param files: Files to add; when omitted, adds the current directory + :param all: When `True` and `files` is omitted, stage all changes + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = build_add_args(files, all) + return self._run_git(args, path, envs, user, cwd, timeout, request_timeout) + + def commit( + self, + path: str, + message: str, + author_name: Optional[str] = None, + author_email: Optional[str] = None, + allow_empty: bool = False, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Create a commit in the repository. + + :param path: Repository path + :param message: Commit message + :param author_name: Commit author name + :param author_email: Commit author email + :param allow_empty: Allow empty commits when `True` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = build_commit_args(message, author_name, author_email, allow_empty) + return self._run_git(args, path, envs, user, cwd, timeout, request_timeout) + + def reset( + self, + path: str, + mode: Optional[GitResetMode] = None, + target: Optional[str] = None, + paths: Optional[List[str]] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Reset the current HEAD to a specified state. + + :param path: Repository path + :param mode: Reset mode (soft, mixed, hard, merge, keep) + :param target: Commit, branch, or ref to reset to (defaults to HEAD) + :param paths: Paths to reset + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = build_reset_args(mode, target, paths) + return self._run_git(args, path, envs, user, cwd, timeout, request_timeout) + + def restore( + self, + path: str, + paths: List[str], + staged: Optional[bool] = None, + worktree: Optional[bool] = None, + source: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Restore working tree files or unstage changes. + + :param path: Repository path + :param paths: Paths to restore (use ["."] for all) + :param staged: When True, restore the index (unstage) + :param worktree: When True, restore working tree files + :param source: Restore from the given source (commit, branch, or ref) + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = build_restore_args(paths, staged, worktree, source) + return self._run_git(args, path, envs, user, cwd, timeout, request_timeout) + + def push( + self, + path: str, + remote: Optional[str] = None, + branch: Optional[str] = None, + set_upstream: bool = True, + username: Optional[str] = None, + password: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Push commits to a remote. + + :param path: Repository path + :param remote: Remote name, e.g. `origin` + :param branch: Branch name to push + :param set_upstream: Set upstream tracking when `True` + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if password and not username: + raise InvalidArgumentException( + "Username is required when using a password or token for git push." + ) + + if username and password: + remote_name = self._resolve_remote_name( + path, remote, envs, user, cwd, timeout, request_timeout + ) + return self._with_remote_credentials( + path, + remote_name, + username, + password, + envs, + user, + cwd, + timeout, + request_timeout, + operation=lambda: self._run_git( + build_push_args( + remote_name, + remote=remote, + branch=branch, + set_upstream=set_upstream, + ), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ), + ) + + try: + return self._run_git( + build_push_args( + None, + remote=remote, + branch=branch, + set_upstream=set_upstream, + ), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + except CommandExitException as err: + if is_auth_failure(err): + raise GitAuthException( + build_auth_error_message("push", bool(username) and not password) + ) from err + if is_missing_upstream(err): + raise GitUpstreamException( + build_upstream_error_message("push") + ) from err + raise + + def pull( + self, + path: str, + remote: Optional[str] = None, + branch: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Pull changes from a remote. + + :param path: Repository path + :param remote: Remote name, e.g. `origin` + :param branch: Branch name to pull + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if password and not username: + raise InvalidArgumentException( + "Username is required when using a password or token for git pull." + ) + + if not remote and not branch: + if not self._has_upstream(path, envs, user, cwd, timeout, request_timeout): + raise GitUpstreamException(build_upstream_error_message("pull")) + + if username and password: + remote_name = self._resolve_remote_name( + path, remote, envs, user, cwd, timeout, request_timeout + ) + return self._with_remote_credentials( + path, + remote_name, + username, + password, + envs, + user, + cwd, + timeout, + request_timeout, + operation=lambda: self._run_git( + build_pull_args(remote, branch, remote_name), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ), + ) + + try: + return self._run_git( + build_pull_args(remote, branch), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + except CommandExitException as err: + if is_auth_failure(err): + raise GitAuthException( + build_auth_error_message("pull", bool(username) and not password) + ) from err + if is_missing_upstream(err): + raise GitUpstreamException( + build_upstream_error_message("pull") + ) from err + raise + + def set_config( + self, + key: str, + value: str, + scope: str = "global", + path: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Set a git config value. + + Use `scope="local"` together with `path` to configure a specific repository. + + :param key: Git config key (e.g. `pull.rebase`) + :param value: Git config value + :param scope: Config scope: `global`, `local`, or `system` + :param path: Repository path required when `scope` is `local` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if not key: + raise InvalidArgumentException("Git config key is required.") + + scope_flag, repo_path = resolve_config_scope(scope, path) + return self._run_git( + ["config", scope_flag, key, value], + repo_path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + def get_config( + self, + key: str, + scope: str = "global", + path: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> Optional[str]: + """ + Get a git config value. + + Returns `None` when the key is not set in the requested scope. + + :param key: Git config key (e.g. `pull.rebase`) + :param scope: Config scope: `global`, `local`, or `system` + :param path: Repository path required when `scope` is `local` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Config value if present, otherwise `None` + """ + if not key: + raise InvalidArgumentException("Git config key is required.") + + scope_flag, repo_path = resolve_config_scope(scope, path) + cmd = ( + f"{build_git_command(['config', scope_flag, '--get', key], repo_path)} " + "|| true" + ) + result = self._run_shell( + cmd, + envs, + user, + cwd, + timeout, + request_timeout, + ).stdout.strip() + return result or None + + def dangerously_authenticate( + self, + username: str, + password: str, + host: str = "github.com", + protocol: str = "https", + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Dangerously authenticate git globally via the credential helper. + + This persists credentials in the credential store and may be accessible to agents running on the sandbox. + Prefer short-lived credentials when possible. + + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :param host: Host to authenticate for, defaults to `github.com` + :param protocol: Protocol to authenticate for, defaults to `https` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if not username or not password: + raise InvalidArgumentException( + "Both username and password are required to authenticate git." + ) + + self.set_config( + "credential.helper", + "store", + scope="global", + envs=envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) + approve_cmd = build_credential_approve_command( + username=username, + password=password, + host=host, + protocol=protocol, + ) + return self._run_shell( + approve_cmd, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + def configure_user( + self, + name: str, + email: str, + scope: str = "global", + path: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Configure git user name and email. + + :param name: Git user name + :param email: Git user email + :param scope: Config scope: `global`, `local`, or `system` + :param path: Repository path required when `scope` is `local` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if not name or not email: + raise InvalidArgumentException("Both name and email are required.") + + self.set_config( + "user.name", + name, + scope=scope, + path=path, + envs=envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) + return self.set_config( + "user.email", + email, + scope=scope, + path=path, + envs=envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py new file mode 100644 index 0000000..df73f2b --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -0,0 +1,975 @@ +import datetime +import json +import logging +import shlex +import uuid +from typing import Dict, List, Optional, Union, overload + +import httpx +from packaging.version import Version +from typing_extensions import Self, Unpack + +from e2b.api.client.types import Unset +from e2b.connection_config import ApiParams, ConnectionConfig +from e2b.envd.api import ENVD_API_HEALTH_ROUTE, handle_envd_api_exception +from e2b.envd.versions import ENVD_DEBUG_FALLBACK +from e2b.exceptions import ( + TemplateException, + format_request_timeout_error, +) +from e2b.sandbox.main import SandboxOpts +from e2b.sandbox.sandbox_api import ( + McpServer, + SandboxLifecycle, + SandboxMetrics, + SandboxNetworkOpts, + SandboxNetworkUpdate, + SnapshotInfo, +) +from e2b.sandbox.utils import class_method_variant +from e2b.sandbox_sync.commands.command import Commands +from e2b.sandbox_sync.commands.pty import Pty +from e2b.sandbox_sync.filesystem.filesystem import Filesystem +from e2b.sandbox_sync.git import Git +from e2b.sandbox_sync.sandbox_api import SandboxApi, SandboxInfo +from e2b.sandbox_sync.paginator import SnapshotPaginator +from e2b.api.client.models import SandboxVolumeMount as SandboxVolumeMountAPI +from e2b.volume.volume_sync import Volume + +logger = logging.getLogger(__name__) + +SandboxVolumeMount = Dict[str, Union[Volume, str]] + + +class Sandbox(SandboxApi): + """ + E2B cloud sandbox is a secure and isolated cloud environment. + + The sandbox allows you to: + - Access Linux OS + - Create, list, and delete files and directories + - Run commands + - Run isolated code + - Access the internet + + Check docs [here](https://e2b.dev/docs). + + Use the `Sandbox.create()` to create a new sandbox. + + Example: + ```python + from e2b import Sandbox + + sandbox = Sandbox.create() + ``` + """ + + @property + def files(self) -> Filesystem: + """ + Module for interacting with the sandbox filesystem. + """ + return self._filesystem + + @property + def commands(self) -> Commands: + """ + Module for running commands in the sandbox. + """ + return self._commands + + @property + def pty(self) -> Pty: + """ + Module for interacting with the sandbox pseudo-terminal. + """ + return self._pty + + @property + def git(self) -> Git: + """ + Module for running git operations in the sandbox. + """ + return self._git + + def __init__(self, **opts: Unpack[SandboxOpts]): + """ + :deprecated: This constructor is deprecated + + Use `Sandbox.create()` to create a new sandbox instead. + """ + super().__init__(**opts) + + self._filesystem = Filesystem( + self.envd_api_url, + self._envd_version, + self.connection_config, + ) + self._commands = Commands( + self.envd_api_url, + self.connection_config, + self._envd_version, + ) + self._pty = Pty( + self.envd_api_url, + self.connection_config, + self._envd_version, + ) + self._git = Git(self._commands) + + @property + def _envd_api(self) -> httpx.Client: + return self._filesystem._envd_api + + def is_running(self, request_timeout: Optional[float] = None) -> bool: + """ + Check if the sandbox is running. + + :param request_timeout: Timeout for the request in **seconds** + + :return: `True` if the sandbox is running, `False` otherwise + + Example + ```python + sandbox = Sandbox.create() + sandbox.is_running() # Returns True + + sandbox.kill() + sandbox.is_running() # Returns False + ``` + """ + try: + r = self._envd_api.get( + ENVD_API_HEALTH_ROUTE, + timeout=self.connection_config.get_request_timeout(request_timeout), + ) + + if r.status_code == 502: + return False + + err = handle_envd_api_exception(r) + + if err: + raise err + + except httpx.TimeoutException: + raise format_request_timeout_error() + + return True + + @classmethod + def create( + cls, + template: Optional[str] = None, + timeout: Optional[int] = None, + metadata: Optional[Dict[str, str]] = None, + envs: Optional[Dict[str, str]] = None, + secure: bool = True, + allow_internet_access: bool = True, + mcp: Optional[McpServer] = None, + network: Optional[SandboxNetworkOpts] = None, + lifecycle: Optional[SandboxLifecycle] = None, + volume_mounts: Optional[SandboxVolumeMount] = None, + logger: Optional[logging.Logger] = None, + **opts: Unpack[ApiParams], + ) -> Self: + """ + Create a new sandbox. + + By default, the sandbox is created from the default `base` sandbox template. + + :param template: Sandbox template name or ID + :param timeout: Timeout for the sandbox in **seconds**, default to 300 seconds. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + :param metadata: Custom metadata for the sandbox + :param envs: Custom environment variables for the sandbox + :param secure: Envd is secured with access token and cannot be used without it, defaults to `True`. + :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. + :param mcp: MCP server to enable in the sandbox + :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``. + :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` (default) or ``"pause"``, or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` (default ``True``) set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); ``auto_resume``: ``False`` (default) or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}`` + :param volume_mounts: Dictionary mapping mount paths to Volume instances or volume names + :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. + + :return: A Sandbox instance for the new sandbox + + Use this method instead of using the constructor to create a new sandbox. + """ + if not template and mcp is not None: + template = cls.default_mcp_template + elif not template: + template = cls.default_template + + transformed_mounts = None + if volume_mounts: + transformed_mounts = [ + SandboxVolumeMountAPI( + name=vol.name if isinstance(vol, Volume) else vol, + path=path, + ) + for path, vol in volume_mounts.items() + ] + + sandbox = cls._create( + template=template, + timeout=timeout, + metadata=metadata, + envs=envs, + secure=secure, + allow_internet_access=allow_internet_access, + mcp=mcp, + network=network, + lifecycle=lifecycle, + volume_mounts=transformed_mounts, + logger=logger, + **opts, + ) + + if mcp is not None: + token = str(uuid.uuid4()) + sandbox._mcp_token = token + + res = sandbox.commands.run( + f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}", + user="root", + envs={"GATEWAY_ACCESS_TOKEN": token}, + ) + if res.exit_code != 0: + raise Exception(f"Failed to start MCP gateway: {res.stderr}") + + return sandbox + + @overload + def connect( + self, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> Self: + """ + Connect to a sandbox. If the sandbox is paused, it will be automatically resumed. + Sandbox must be either running or be paused. + + With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc). + + :param timeout: Timeout for the sandbox in **seconds** + For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :return: A running sandbox instance + + @example + ```python + sandbox = Sandbox.create() + sandbox.pause() + + # Another code block + same_sandbox = sandbox.connect() + + :return: A running sandbox instance + """ + ... + + @overload + @staticmethod + def connect( + sandbox_id: str, + timeout: Optional[int] = None, + logger: Optional[logging.Logger] = None, + **opts: Unpack[ApiParams], + ) -> "Sandbox": + """ + Connect to a sandbox. If the sandbox is paused, it will be automatically resumed. + Sandbox must be either running or be paused. + + With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc). + + :param sandbox_id: Sandbox ID + :param timeout: Timeout for the sandbox in **seconds**. + For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. + :return: A running sandbox instance + + @example + ```python + sandbox = Sandbox.create() + Sandbox.pause(sandbox.sandbox_id) + + # Another code block + same_sandbox = Sandbox.connect(sandbox.sandbox_id) + ``` + """ + ... + + @class_method_variant("_cls_connect_sandbox") + def connect( + self, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> Self: + """ + Connect to a sandbox. If the sandbox is paused, it will be automatically resumed. + Sandbox must be either running or be paused. + + With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc). + + :param timeout: Timeout for the sandbox in **seconds**. + For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :return: A running sandbox instance + + @example + ```python + sandbox = Sandbox.create() + sandbox.pause() + + # Another code block + same_sandbox = sandbox.connect() + ``` + """ + if self.connection_config.debug: + # Skip connecting to the sandbox in debug mode + return self + + SandboxApi._cls_connect( + sandbox_id=self.sandbox_id, + timeout=timeout, + **self.connection_config.get_api_params(**opts), + ) + + return self + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.kill() + + @overload + def kill( + self, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Kill the sandbox. + + :return: `True` if the sandbox was killed, `False` if the sandbox was not found + """ + ... + + @overload + @staticmethod + def kill( + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Kill the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + + :return: `True` if the sandbox was killed, `False` if the sandbox was not found + """ + ... + + @class_method_variant("_cls_kill") + def kill( + self, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Kill the sandbox specified by sandbox ID. + + :return: `True` if the sandbox was killed, `False` if the sandbox was not found + """ + if self.connection_config.debug: + # Skip killing the sandbox in debug mode + return True + + return SandboxApi._cls_kill( + sandbox_id=self.sandbox_id, + **self.connection_config.get_api_params(**opts), + ) + + @overload + def set_timeout( + self, + timeout: int, + **opts: Unpack[ApiParams], + ) -> None: + """ + Set the timeout of the sandbox. + This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.set_timeout`. + + The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + + :param timeout: Timeout for the sandbox in **seconds** + """ + ... + + @overload + @staticmethod + def set_timeout( + sandbox_id: str, + timeout: int, + **opts: Unpack[ApiParams], + ) -> None: + """ + Set the timeout of the sandbox specified by sandbox ID. + This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.set_timeout`. + + The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + + :param sandbox_id: Sandbox ID + :param timeout: Timeout for the sandbox in **seconds** + """ + ... + + @class_method_variant("_cls_set_timeout") + def set_timeout( + self, + timeout: int, + **opts: Unpack[ApiParams], + ) -> None: + """ + Set the timeout of the sandbox. + This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.set_timeout`. + + The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + + :param timeout: Timeout for the sandbox in **seconds** + + """ + + SandboxApi._cls_set_timeout( + sandbox_id=self.sandbox_id, + timeout=timeout, + **self.connection_config.get_api_params(**opts), + ) + + @overload + def update_network( + self, + network: SandboxNetworkUpdate, + **opts: Unpack[ApiParams], + ) -> None: + """ + Update the network configuration of the sandbox. + + Replaces the current egress configuration atomically — fields that are + omitted are cleared on the server. + + :param network: New network configuration. + """ + ... + + @overload + @staticmethod + def update_network( + sandbox_id: str, + network: SandboxNetworkUpdate, + **opts: Unpack[ApiParams], + ) -> None: + """ + Update the network configuration of the sandbox specified by sandbox ID. + + Replaces the current egress configuration atomically — fields that are + omitted are cleared on the server. + + :param sandbox_id: Sandbox ID. + :param network: New network configuration. + """ + ... + + @class_method_variant("_cls_update_network") + def update_network( + self, + network: SandboxNetworkUpdate, + **opts: Unpack[ApiParams], + ) -> None: + """ + Update the network configuration of the sandbox. + + Replaces the current egress configuration atomically — fields that are + omitted are cleared on the server. + + :param network: New network configuration. + """ + + SandboxApi._cls_update_network( + sandbox_id=self.sandbox_id, + network=network, + **self.connection_config.get_api_params(**opts), + ) + + @overload + def get_info( + self, + **opts: Unpack[ApiParams], + ) -> SandboxInfo: + """ + Get sandbox information like sandbox ID, template, metadata, started at/end at date. + + :return: Sandbox info + """ + ... + + @overload + @staticmethod + def get_info( + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> SandboxInfo: + """ + Get sandbox information like sandbox ID, template, metadata, started at/end at date. + + :param sandbox_id: Sandbox ID + + :return: Sandbox info + """ + ... + + @class_method_variant("_cls_get_info") + def get_info( + self, + **opts: Unpack[ApiParams], + ) -> SandboxInfo: + """ + Get sandbox information like sandbox ID, template, metadata, started at/end at date. + + :return: Sandbox info + """ + return SandboxApi._cls_get_info( + sandbox_id=self.sandbox_id, + **self.connection_config.get_api_params(**opts), + ) + + @overload + def get_metrics( + self, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + **opts: Unpack[ApiParams], + ) -> List[SandboxMetrics]: + """ + Get the metrics of the current sandbox. + + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to the current time + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + ... + + @overload + @staticmethod + def get_metrics( + sandbox_id: str, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + **opts: Unpack[ApiParams], + ) -> List[SandboxMetrics]: + """ + Get the metrics of the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to the current time + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + ... + + @class_method_variant("_cls_get_metrics") + def get_metrics( + self, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + **opts: Unpack[ApiParams], + ) -> List[SandboxMetrics]: + """ + Get the metrics of the current sandbox. + + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to the current time + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + if self.connection_config.debug: + # Skip getting the metrics in debug mode + return [] + + if self._envd_version < Version("0.1.5"): + raise TemplateException( + "You need to update the template to use the new SDK." + ) + + if self._envd_version < Version("0.2.4"): + logger.warning( + "Disk metrics are not supported in this version of the sandbox, please rebuild the template to get disk metrics." + ) + + return SandboxApi._cls_get_metrics( + sandbox_id=self.sandbox_id, + start=start, + end=end, + **self.connection_config.get_api_params(**opts), + ) + + @overload + def pause( + self, + keep_memory: bool = True, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Pause the sandbox. + + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. Defaults to `True`. + + :return: `True` if the sandbox got paused, `False` if the sandbox was already paused + """ + ... + + @overload + @staticmethod + def pause( + sandbox_id: str, + keep_memory: bool = True, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Pause the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. Defaults to `True`. + + :return: `True` if the sandbox got paused, `False` if the sandbox was already paused + """ + ... + + @class_method_variant("_cls_pause") + def pause( + self, + keep_memory: bool = True, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Pause the sandbox. + + :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. Defaults to `True` (full memory snapshot). + + :return: `True` if the sandbox got paused, `False` if the sandbox was already paused + """ + + return SandboxApi._cls_pause( + sandbox_id=self.sandbox_id, + keep_memory=keep_memory, + **self.connection_config.get_api_params(**opts), + ) + + @overload + def beta_pause( + self, + keep_memory: bool = True, + **opts: Unpack[ApiParams], + ) -> bool: ... + + @overload + @staticmethod + def beta_pause( + sandbox_id: str, + keep_memory: bool = True, + **opts: Unpack[ApiParams], + ) -> bool: ... + + @class_method_variant("_cls_pause") + def beta_pause( + self, + keep_memory: bool = True, + **opts: Unpack[ApiParams], + ) -> bool: + """ + :deprecated: Use `pause()` instead. + + :return: `True` if the sandbox got paused, `False` if the sandbox was already paused + """ + return self.pause(keep_memory=keep_memory, **opts) + + @overload + def create_snapshot( + self, + name: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SnapshotInfo: + """ + Create a snapshot of the sandbox's current state. + + The sandbox will be paused while the snapshot is being created. + The snapshot can be used to create new sandboxes with the same filesystem and state. + Snapshots are persistent and survive sandbox deletion. + + Use the returned `snapshot_id` with `Sandbox.create(snapshot_id)` to create a new sandbox from the snapshot. + + :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + + :return: Snapshot information including the snapshot ID and names + """ + ... + + @overload + @staticmethod + def create_snapshot( + sandbox_id: str, + name: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SnapshotInfo: + """ + Create a snapshot from the sandbox specified by sandbox ID. + + The sandbox will be paused while the snapshot is being created. + + :param sandbox_id: Sandbox ID + :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + + :return: Snapshot information including the snapshot ID and names + """ + ... + + @class_method_variant("_cls_create_snapshot") + def create_snapshot( + self, + name: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SnapshotInfo: + """ + Create a snapshot of the sandbox's current state. + + The sandbox will be paused while the snapshot is being created. + The snapshot can be used to create new sandboxes with the same filesystem and state. + Snapshots are persistent and survive sandbox deletion. + + Use the returned `snapshot_id` with `Sandbox.create(snapshot_id)` to create a new sandbox from the snapshot. + + :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + + :return: Snapshot information including the snapshot ID and names + """ + return SandboxApi._cls_create_snapshot( + sandbox_id=self.sandbox_id, + name=name, + **self.connection_config.get_api_params(**opts), + ) + + @overload + def list_snapshots( + self, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SnapshotPaginator: + """ + List snapshots for this sandbox. + + :param limit: Maximum number of snapshots to return per page + :param next_token: Token for pagination + + :return: Paginator for listing snapshots + """ + ... + + @overload + @staticmethod + def list_snapshots( + sandbox_id: Optional[str] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SnapshotPaginator: + """ + List all snapshots. + + :param sandbox_id: Filter snapshots by source sandbox ID + :param limit: Maximum number of snapshots to return per page + :param next_token: Token for pagination + + :return: Paginator for listing snapshots + """ + ... + + @class_method_variant("_cls_list_snapshots") + def list_snapshots( + self, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SnapshotPaginator: + """ + List snapshots for this sandbox. + + :param limit: Maximum number of snapshots to return per page + :param next_token: Token for pagination + + :return: Paginator for listing snapshots + """ + return SnapshotPaginator( + sandbox_id=self.sandbox_id, + limit=limit, + next_token=next_token, + **self.connection_config.get_api_params(**opts), + ) + + @staticmethod + def _cls_list_snapshots( + sandbox_id: Optional[str] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SnapshotPaginator: + return SnapshotPaginator( + sandbox_id=sandbox_id, + limit=limit, + next_token=next_token, + **opts, + ) + + @staticmethod + def delete_snapshot( + snapshot_id: str, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Delete a snapshot. + + :param snapshot_id: Snapshot ID + :return: `True` if the snapshot was deleted, `False` if it was not found + """ + return SandboxApi._cls_delete_snapshot( + snapshot_id=snapshot_id, + **opts, + ) + + def get_mcp_token(self) -> Optional[str]: + """ + Get the MCP token for the sandbox. + + :return: MCP token for the sandbox, or None if MCP is not enabled. + """ + if not self._mcp_token: + self._mcp_token = self.files.read("/etc/mcp-gateway/.token", user="root") + return self._mcp_token + + @classmethod + def _cls_connect_sandbox( + cls, + sandbox_id: str, + timeout: Optional[int] = None, + logger: Optional[logging.Logger] = None, + **opts: Unpack[ApiParams], + ) -> Self: + debug = ConnectionConfig(**opts).debug + if debug: + sandbox_domain = None + envd_version = ENVD_DEBUG_FALLBACK + envd_access_token = None + traffic_access_token = None + else: + sandbox = SandboxApi._cls_connect( + sandbox_id=sandbox_id, + timeout=timeout, + logger=logger, + **opts, + ) + + sandbox_id = sandbox.sandbox_id + sandbox_domain = sandbox.sandbox_domain + envd_version = Version(sandbox.envd_version) + envd_access_token = sandbox.envd_access_token + traffic_access_token = sandbox.traffic_access_token + + sandbox_headers = { + "E2b-Sandbox-Id": sandbox_id, + "E2b-Sandbox-Port": str(ConnectionConfig.envd_port), + } + if envd_access_token is not None and not isinstance(envd_access_token, Unset): + sandbox_headers["X-Access-Token"] = envd_access_token + + connection_config = ConnectionConfig( + extra_sandbox_headers=sandbox_headers, + logger=logger, + **opts, + ) + + return cls( + sandbox_id=sandbox_id, + sandbox_domain=sandbox_domain, + envd_version=envd_version, + envd_access_token=envd_access_token, + traffic_access_token=traffic_access_token, + connection_config=connection_config, + ) + + @classmethod + def _create( + cls, + template: Optional[str], + timeout: Optional[int], + metadata: Optional[Dict[str, str]], + envs: Optional[Dict[str, str]], + secure: bool, + allow_internet_access: bool, + mcp: Optional[McpServer] = None, + network: Optional[SandboxNetworkOpts] = None, + lifecycle: Optional[SandboxLifecycle] = None, + volume_mounts: Optional[list] = None, + logger: Optional[logging.Logger] = None, + **opts: Unpack[ApiParams], + ) -> Self: + extra_sandbox_headers = {} + + debug = ConnectionConfig(**opts).debug + if debug: + sandbox_id = "debug_sandbox_id" + sandbox_domain = None + envd_version = ENVD_DEBUG_FALLBACK + envd_access_token = None + traffic_access_token = None + else: + response = SandboxApi._create_sandbox( + template=template or cls.default_template, + timeout=timeout or cls.default_sandbox_timeout, + metadata=metadata, + env_vars=envs, + secure=secure, + allow_internet_access=allow_internet_access, + mcp=mcp, + network=network, + lifecycle=lifecycle, + volume_mounts=volume_mounts, + logger=logger, + **opts, + ) + + sandbox_id = response.sandbox_id + sandbox_domain = response.sandbox_domain + envd_version = Version(response.envd_version) + envd_access_token = response.envd_access_token + traffic_access_token = response.traffic_access_token + + if envd_access_token is not None and not isinstance( + envd_access_token, Unset + ): + extra_sandbox_headers["X-Access-Token"] = envd_access_token + + extra_sandbox_headers["E2b-Sandbox-Id"] = sandbox_id + extra_sandbox_headers["E2b-Sandbox-Port"] = str(ConnectionConfig.envd_port) + + connection_config = ConnectionConfig( + extra_sandbox_headers=extra_sandbox_headers, + logger=logger, + **opts, + ) + + return cls( + sandbox_id=sandbox_id, + sandbox_domain=sandbox_domain, + envd_version=envd_version, + envd_access_token=envd_access_token, + traffic_access_token=traffic_access_token, + connection_config=connection_config, + ) diff --git a/packages/python-sdk/e2b/sandbox_sync/paginator.py b/packages/python-sdk/e2b/sandbox_sync/paginator.py new file mode 100644 index 0000000..9f0d4bf --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_sync/paginator.py @@ -0,0 +1,140 @@ +import urllib.parse +from typing import Optional, List + +from typing_extensions import Unpack + +from e2b.api import handle_api_exception +from e2b.api.client.api.sandboxes import get_v2_sandboxes +from e2b.api.client.api.snapshots import get_snapshots +from e2b.api.client.models.error import Error +from e2b.api.client.types import UNSET +from e2b.connection_config import ApiParams, ConnectionConfig +from e2b.exceptions import SandboxException +from e2b.sandbox.sandbox_api import ( + SandboxPaginatorBase, + SandboxInfo, + SnapshotPaginatorBase, + SnapshotInfo, +) +from e2b.api.client_sync import get_api_client + + +class SandboxPaginator(SandboxPaginatorBase): + """ + Paginator for listing sandboxes. + + Example: + ```python + paginator = Sandbox.list() + + while paginator.has_next: + sandboxes = paginator.next_items() + print(sandboxes) + ``` + """ + + def next_items(self, **opts: Unpack[ApiParams]) -> List[SandboxInfo]: + """ + Returns the next page of sandboxes. + + Call this method only if `has_next` is `True`, otherwise it will raise an exception. + + :param opts: Per-call connection options (e.g. `api_key`, `domain`, + `headers`, `request_timeout`). When provided, this call uses these + options instead of the ones the paginator was constructed with. + + :returns: List of sandboxes + """ + if not self.has_next: + raise Exception("No more items to fetch") + + # Convert filters to the format expected by the API + metadata: Optional[str] = None + if self.query and self.query.metadata: + quoted_metadata = { + urllib.parse.quote(k): urllib.parse.quote(v) + for k, v in self.query.metadata.items() + } + metadata = urllib.parse.urlencode(quoted_metadata) + + config = ConnectionConfig(**{**self._opts, **opts}) + api_client = get_api_client(config) + res = get_v2_sandboxes.sync_detailed( + client=api_client, + metadata=metadata if metadata else UNSET, + state=self.query.state if self.query and self.query.state else UNSET, + limit=self.limit if self.limit else UNSET, + next_token=self._next_token if self._next_token else UNSET, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + self._update_pagination(res.headers) + + if res.parsed is None: + return [] + + # Check if res.parsed is Error + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return [SandboxInfo._from_listed_sandbox(sandbox) for sandbox in res.parsed] + + +class SnapshotPaginator(SnapshotPaginatorBase): + """ + Paginator for listing snapshots. + + Example: + ```python + paginator = Sandbox.list_snapshots() + + while paginator.has_next: + snapshots = paginator.next_items() + print(snapshots) + ``` + """ + + def next_items(self, **opts: Unpack[ApiParams]) -> List[SnapshotInfo]: + """ + Returns the next page of snapshots. + + Call this method only if `has_next` is `True`, otherwise it will raise an exception. + + :param opts: Per-call connection options (e.g. `api_key`, `domain`, + `headers`, `request_timeout`). When provided, this call uses these + options instead of the ones the paginator was constructed with. + + :returns: List of snapshots + """ + if not self.has_next: + raise Exception("No more items to fetch") + + config = ConnectionConfig(**{**self._opts, **opts}) + api_client = get_api_client(config) + res = get_snapshots.sync_detailed( + client=api_client, + sandbox_id=self.sandbox_id if self.sandbox_id else UNSET, + limit=self.limit if self.limit else UNSET, + next_token=self._next_token if self._next_token else UNSET, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + self._update_pagination(res.headers) + + if res.parsed is None: + return [] + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return [ + SnapshotInfo( + snapshot_id=snapshot.snapshot_id, + names=list(snapshot.names) if snapshot.names else [], + ) + for snapshot in res.parsed + ] diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py new file mode 100644 index 0000000..6ddcd07 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -0,0 +1,491 @@ +import datetime +import logging +from typing import Any, Dict, List, Optional, cast + +from packaging.version import Version +from typing_extensions import Unpack + +from e2b.api import SandboxCreateResponse, handle_api_exception +from e2b.api.client.api.sandboxes import ( + delete_sandboxes_sandbox_id, + get_sandboxes_sandbox_id, + get_sandboxes_sandbox_id_metrics, + post_sandboxes, + post_sandboxes_sandbox_id_connect, + post_sandboxes_sandbox_id_pause, + post_sandboxes_sandbox_id_snapshots, + post_sandboxes_sandbox_id_timeout, + put_sandboxes_sandbox_id_network, +) +from e2b.api.client.api.templates import delete_templates_template_id +from e2b.api.client.models import ( + ConnectSandbox, + Error, + NewSandbox, + PostSandboxesSandboxIDSnapshotsBody, + PostSandboxesSandboxIDTimeoutBody, + SandboxAutoResumeConfig, + SandboxNetworkConfig, + SandboxPauseRequest, + SandboxVolumeMount as SandboxVolumeMountAPI, +) +from e2b.api.client.types import UNSET +from e2b.connection_config import ApiParams, ConnectionConfig +from e2b.exceptions import ( + InvalidArgumentException, + SandboxException, + SandboxNotFoundException, + TemplateException, +) +from e2b.sandbox.main import SandboxBase +from e2b.sandbox.sandbox_api import ( + build_network_update_body, + McpServer, + SandboxInfo, + SandboxLifecycle, + SandboxMetrics, + SandboxNetworkOpts, + SandboxNetworkUpdate, + SandboxQuery, + SnapshotInfo, + build_network_config, +) +from e2b.sandbox_sync.paginator import SandboxPaginator, get_api_client + + +class SandboxApi(SandboxBase): + @staticmethod + def list( + query: Optional[SandboxQuery] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SandboxPaginator: + """ + List sandboxes. + + By default (no `query.state` set), returns sandboxes in both `running` + and `paused` states. To filter by state, pass `query=SandboxQuery(state=[...])`. + + :param query: Filter the list of sandboxes by metadata or state, e.g. `SandboxQuery(metadata={"key": "value"})` or `SandboxQuery(state=[SandboxState.RUNNING])` + :param limit: Maximum number of sandboxes to return per page + :param next_token: Token for pagination + + :return: A `SandboxPaginator` that yields pages of sandboxes (running and paused by default). Iterate pages via `paginator.next_items()` while `paginator.has_next` is True. + """ + return SandboxPaginator( + query=query, + limit=limit, + next_token=next_token, + **opts, + ) + + @classmethod + def _cls_get_info( + cls, + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> SandboxInfo: + """ + Get the sandbox info. + :param sandbox_id: Sandbox ID + + :return: Sandbox info + """ + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = get_sandboxes_sandbox_id.sync_detailed( + sandbox_id, + client=api_client, + ) + + if res.status_code == 404: + raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return SandboxInfo._from_sandbox_detail(res.parsed) + + @classmethod + def _cls_kill( + cls, + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> bool: + config = ConnectionConfig(**opts) + + if config.debug: + # Skip killing the sandbox in debug mode + return True + + api_client = get_api_client(config) + res = delete_sandboxes_sandbox_id.sync_detailed( + sandbox_id, + client=api_client, + ) + + if res.status_code == 404: + return False + + if res.status_code >= 300: + raise handle_api_exception(res) + + return True + + @classmethod + def _cls_set_timeout( + cls, + sandbox_id: str, + timeout: int, + **opts: Unpack[ApiParams], + ) -> None: + config = ConnectionConfig(**opts) + + if config.debug: + # Skip setting timeout in debug mode + return + + api_client = get_api_client(config) + res = post_sandboxes_sandbox_id_timeout.sync_detailed( + sandbox_id, + client=api_client, + body=PostSandboxesSandboxIDTimeoutBody(timeout=timeout), + ) + + if res.status_code == 404: + raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + @classmethod + def _cls_update_network( + cls, + sandbox_id: str, + network: SandboxNetworkUpdate, + **opts: Unpack[ApiParams], + ) -> None: + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = put_sandboxes_sandbox_id_network.sync_detailed( + sandbox_id, + client=api_client, + body=build_network_update_body(network), + ) + + if res.status_code == 404: + raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + @classmethod + def _create_sandbox( + cls, + template: str, + timeout: int, + allow_internet_access: bool, + metadata: Optional[Dict[str, str]], + env_vars: Optional[Dict[str, str]], + secure: bool, + mcp: Optional[McpServer] = None, + network: Optional[SandboxNetworkOpts] = None, + lifecycle: Optional[SandboxLifecycle] = None, + volume_mounts: Optional[List[SandboxVolumeMountAPI]] = None, + logger: Optional[logging.Logger] = None, + **opts: Unpack[ApiParams], + ) -> SandboxCreateResponse: + config = ConnectionConfig(logger=logger, **opts) + + # on_timeout accepts a bare action or {"action", "keep_memory"}; normalize. + # Only the object form carries keep_memory; anything else (a bare action + # string, or an unexpected value from an untyped caller) passes through as + # the action, so a non-"pause" value resolves to kill instead of crashing. + on_timeout_raw = lifecycle.get("on_timeout", "kill") if lifecycle else "kill" + if isinstance(on_timeout_raw, dict): + on_timeout = on_timeout_raw.get("action", "kill") + keep_memory_provided = "keep_memory" in on_timeout_raw + keep_memory = on_timeout_raw.get("keep_memory") + else: + on_timeout = on_timeout_raw + keep_memory = None + keep_memory_provided = False + + # keep_memory only governs a pause action. The discriminated union type + # forbids it on action="kill"; re-check at runtime for callers that + # bypass the type. + if keep_memory_provided and on_timeout != "pause": + raise InvalidArgumentException( + "keep_memory is only allowed when on_timeout action is 'pause'." + ) + + # A missing or explicit None keep_memory defaults to True (full memory), + # mirroring the JS SDK; sending null would wrongly read as filesystem-only. + if keep_memory is None: + keep_memory = True + auto_resume = lifecycle.get("auto_resume", False) if lifecycle else False + + if auto_resume and on_timeout != "pause": + raise InvalidArgumentException( + "auto_resume can only be True when on_timeout action is 'pause'." + ) + + if not keep_memory and auto_resume: + raise InvalidArgumentException( + "auto_resume: True is not a valid value when keep_memory: False - " + "a filesystem-only snapshot cannot be auto-resumed by traffic and " + "must be resumed explicitly using Sandbox.connect()." + ) + + network_body = build_network_config(network) + body = NewSandbox( + template_id=template, + auto_pause=on_timeout == "pause", + auto_pause_memory=keep_memory if on_timeout == "pause" else UNSET, + auto_resume=SandboxAutoResumeConfig(enabled=auto_resume), + metadata=metadata or {}, + timeout=timeout, + env_vars=env_vars or {}, + mcp=cast(Any, mcp) or UNSET, + secure=secure, + allow_internet_access=allow_internet_access, + network=SandboxNetworkConfig(**network_body) if network_body else UNSET, + volume_mounts=volume_mounts if volume_mounts else UNSET, + ) + + api_client = get_api_client(config) + res = post_sandboxes.sync_detailed( + body=body, + client=api_client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + if Version(res.parsed.envd_version) < Version("0.1.0"): + SandboxApi._cls_kill(res.parsed.sandbox_id) + raise TemplateException( + "You need to update the template to use the new SDK." + ) + + domain = res.parsed.domain if isinstance(res.parsed.domain, str) else None + envd_token = ( + res.parsed.envd_access_token + if isinstance(res.parsed.envd_access_token, str) + else None + ) + traffic_token = ( + res.parsed.traffic_access_token + if isinstance(res.parsed.traffic_access_token, str) + else None + ) + + return SandboxCreateResponse( + sandbox_id=res.parsed.sandbox_id, + sandbox_domain=domain, + envd_version=res.parsed.envd_version, + envd_access_token=envd_token, + traffic_access_token=traffic_token, + ) + + @classmethod + def _cls_get_metrics( + cls, + sandbox_id: str, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + **opts: Unpack[ApiParams], + ) -> List[SandboxMetrics]: + config = ConnectionConfig(**opts) + + if config.debug: + # Skip getting the metrics in debug mode + return [] + + api_client = get_api_client(config) + res = get_sandboxes_sandbox_id_metrics.sync_detailed( + sandbox_id, + start=int(start.timestamp()) if start else UNSET, + end=int(end.timestamp()) if end else UNSET, + client=api_client, + ) + + if res.status_code == 404: + raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + return [] + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + # Convert to typed SandboxMetrics objects + return [ + SandboxMetrics( + cpu_count=metric.cpu_count, + cpu_used_pct=metric.cpu_used_pct, + disk_total=metric.disk_total, + disk_used=metric.disk_used, + mem_total=metric.mem_total, + mem_used=metric.mem_used, + mem_cache=metric.mem_cache, + timestamp=metric.timestamp, + ) + for metric in res.parsed + ] + + @classmethod + def _cls_connect( + cls, + sandbox_id: str, + timeout: Optional[int] = None, + logger: Optional[logging.Logger] = None, + **opts: Unpack[ApiParams], + ) -> SandboxCreateResponse: + timeout = timeout or SandboxBase.default_sandbox_timeout + + config = ConnectionConfig(logger=logger, **opts) + + api_client = get_api_client(config) + res = post_sandboxes_sandbox_id_connect.sync_detailed( + sandbox_id, + client=api_client, + body=ConnectSandbox(timeout=timeout), + ) + + if res.status_code == 404: + raise SandboxNotFoundException(f"Paused sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + if res.parsed is None: + raise Exception("Body of the request is None") + + domain = res.parsed.domain if isinstance(res.parsed.domain, str) else None + envd_token = ( + res.parsed.envd_access_token + if isinstance(res.parsed.envd_access_token, str) + else None + ) + traffic_token = ( + res.parsed.traffic_access_token + if isinstance(res.parsed.traffic_access_token, str) + else None + ) + + return SandboxCreateResponse( + sandbox_id=res.parsed.sandbox_id, + sandbox_domain=domain, + envd_version=res.parsed.envd_version, + envd_access_token=envd_token, + traffic_access_token=traffic_token, + ) + + @classmethod + def _cls_create_snapshot( + cls, + sandbox_id: str, + name: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SnapshotInfo: + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = post_sandboxes_sandbox_id_snapshots.sync_detailed( + sandbox_id, + client=api_client, + body=PostSandboxesSandboxIDSnapshotsBody(name=name if name else UNSET), + ) + + if res.status_code == 404: + raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return SnapshotInfo( + snapshot_id=res.parsed.snapshot_id, + names=list(res.parsed.names) if res.parsed.names else [], + ) + + @classmethod + def _cls_delete_snapshot( + cls, + snapshot_id: str, + **opts: Unpack[ApiParams], + ) -> bool: + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = delete_templates_template_id.sync_detailed( + snapshot_id, + client=api_client, + ) + + if res.status_code == 404: + return False + + if res.status_code >= 300: + raise handle_api_exception(res) + + return True + + @classmethod + def _cls_pause( + cls, + sandbox_id: str, + keep_memory: bool = True, + **opts: Unpack[ApiParams], + ) -> bool: + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = post_sandboxes_sandbox_id_pause.sync_detailed( + sandbox_id, + client=api_client, + body=SandboxPauseRequest(memory=keep_memory), + ) + + if res.status_code == 404: + raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code == 409: + # Sandbox is already paused + return False + + if res.status_code >= 300: + raise handle_api_exception(res) + + # Check if res.parse is Error + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return True diff --git a/packages/python-sdk/e2b/template/consts.py b/packages/python-sdk/e2b/template/consts.py new file mode 100644 index 0000000..f40d838 --- /dev/null +++ b/packages/python-sdk/e2b/template/consts.py @@ -0,0 +1,45 @@ +""" +Special step name for the finalization phase of template building. +This is the last step that runs after all user-defined instructions. +""" + +FINALIZE_STEP_NAME = "finalize" + +""" +Special step name for the base image phase of template building. +This is the first step that sets up the base image. +""" +BASE_STEP_NAME = "base" + +""" +Stack trace depth for capturing caller information. + +Depth levels: +1. TemplateClass +2. Caller method (e.g., copy(), from_image(), etc.) + +This depth is used to determine the original caller's location +for stack traces. +""" +STACK_TRACE_DEPTH = 2 + +""" +Default setting for whether to resolve symbolic links when copying files. +When False, symlinks are copied as symlinks rather than following them. +""" +RESOLVE_SYMLINKS = False + +""" +Default setting for whether to gzip files when copying them into the +template. When True, the upload archive is gzipped before being uploaded. +""" +GZIP = True + +""" +Default timeout (in seconds) for uploading the build-context archive to the +S3 presigned URL. Uploads of large archives can take far longer than the 60s +general API request timeout, so the upload uses a 1-hour default unless the +caller passes an explicit ``request_timeout``. This matches the JS SDK's +``FILE_UPLOAD_TIMEOUT_MS``. +""" +FILE_UPLOAD_TIMEOUT_SECONDS = 3600 diff --git a/packages/python-sdk/e2b/template/dockerfile_parser.py b/packages/python-sdk/e2b/template/dockerfile_parser.py new file mode 100644 index 0000000..d51bf9e --- /dev/null +++ b/packages/python-sdk/e2b/template/dockerfile_parser.py @@ -0,0 +1,286 @@ +import json +import os +import re +import tempfile +from typing import Dict, List, Optional, Protocol, Union, Literal + +from dockerfile_parse import DockerfileParser + + +class DockerfFileFinalParserInterface(Protocol): + """Protocol defining the final interface for Dockerfile parsing callbacks.""" + + +class DockerfileParserInterface(Protocol): + """Protocol defining the interface for Dockerfile parsing callbacks.""" + + def run_cmd( + self, command: Union[str, List[str]], user: Optional[str] = None + ) -> "DockerfileParserInterface": + """Handle RUN instruction.""" + ... + + def copy( + self, + src: str, + dest: str, + force_upload: Optional[Literal[True]] = None, + user: Optional[str] = None, + mode: Optional[int] = None, + resolve_symlinks: Optional[bool] = None, + gzip: Optional[bool] = None, + ) -> "DockerfileParserInterface": + """Handle COPY instruction.""" + ... + + def set_workdir(self, workdir: str) -> "DockerfileParserInterface": + """Handle WORKDIR instruction.""" + ... + + def set_user(self, user: str) -> "DockerfileParserInterface": + """Handle USER instruction.""" + ... + + def set_envs(self, envs: Dict[str, str]) -> "DockerfileParserInterface": + """Handle ENV instruction.""" + ... + + def set_start_cmd( + self, start_cmd: str, ready_cmd: str + ) -> "DockerfFileFinalParserInterface": + """Handle CMD/ENTRYPOINT instruction.""" + ... + + +def parse_dockerfile( + dockerfile_content_or_path: str, template_builder: DockerfileParserInterface +) -> str: + """ + Parse a Dockerfile and convert it to Template SDK format. + + :param dockerfile_content_or_path: Either the Dockerfile content as a string, or a path to a Dockerfile file + :param template_builder: Interface providing template builder methods + + :return: The base image from the Dockerfile + + :raises ValueError: If the Dockerfile is invalid or unsupported + """ + # Check if input is a file path that exists + if os.path.isfile(dockerfile_content_or_path): + # Read the file content + with open(dockerfile_content_or_path, "r", encoding="utf-8") as f: + dockerfile_content = f.read() + else: + # Treat as content directly + dockerfile_content = dockerfile_content_or_path + + # Use a temporary directory to avoid creating files in the current directory + with tempfile.TemporaryDirectory() as temp_dir: + # Create a temporary Dockerfile + dockerfile_path = os.path.join(temp_dir, "Dockerfile") + with open(dockerfile_path, "w") as f: + f.write(dockerfile_content) + + dfp = DockerfileParser(path=temp_dir) + + # Check for multi-stage builds + from_instructions = [ + instruction + for instruction in dfp.structure + if instruction["instruction"] == "FROM" + ] + + if len(from_instructions) > 1: + raise ValueError("Multi-stage Dockerfiles are not supported") + + if len(from_instructions) == 0: + raise ValueError("Dockerfile must contain a FROM instruction") + + # Set the base image from the first FROM instruction + base_image = from_instructions[0]["value"] + # Remove AS alias if present (e.g., "node:18 AS builder" -> "node:18") + if " as " in base_image.lower(): + base_image = base_image.split(" as ")[0].strip() + + user_changed = False + workdir_changed = False + + # Set the user and workdir to the Docker defaults + template_builder.set_user("root") + template_builder.set_workdir("/") + + # Process all other instructions + for instruction_data in dfp.structure: + instruction = instruction_data["instruction"] + value = instruction_data["value"] + + if instruction == "FROM": + # Already handled above + continue + elif instruction == "RUN": + _handle_run_instruction(value, template_builder) + elif instruction in ["COPY", "ADD"]: + _handle_copy_instruction(value, template_builder) + elif instruction == "WORKDIR": + _handle_workdir_instruction(value, template_builder) + workdir_changed = True + elif instruction == "USER": + _handle_user_instruction(value, template_builder) + user_changed = True + elif instruction in ["ENV", "ARG"]: + _handle_env_instruction(value, instruction, template_builder) + elif instruction in ["CMD", "ENTRYPOINT"]: + _handle_cmd_entrypoint_instruction(value, template_builder) + else: + print(f"Unsupported instruction: {instruction}") + continue + + # Set the user and workdir to the E2B defaults + if not user_changed: + template_builder.set_user("user") + if not workdir_changed: + template_builder.set_workdir("/home/user") + + return base_image + + +def _handle_run_instruction( + value: str, template_builder: DockerfileParserInterface +) -> None: + """Handle RUN instruction""" + if not value.strip(): + return + # Remove line continuations and normalize whitespace + command = re.sub(r"\\\s*\n\s*", " ", value).strip() + template_builder.run_cmd(command) + + +def _handle_copy_instruction( + value: str, template_builder: DockerfileParserInterface +) -> None: + """Handle COPY/ADD instruction""" + if not value.strip(): + return + # Parse source and destination from COPY/ADD command + # Handle both quoted and unquoted paths + parts = [] + current_part = "" + in_quotes = False + quote_char = None + + i = 0 + while i < len(value): + char = value[i] + if char in ['"', "'"] and (i == 0 or value[i - 1] != "\\"): + if not in_quotes: + in_quotes = True + quote_char = char + elif char == quote_char: + in_quotes = False + quote_char = None + else: + current_part += char + elif char == " " and not in_quotes: + if current_part: + parts.append(current_part) + current_part = "" + else: + current_part += char + i += 1 + + if current_part: + parts.append(current_part) + + # Extract --chown flag and separate from paths + user = None + non_flag_parts = [] + for part in parts: + if part.startswith("--chown="): + user = part[8:] # Extract value after "--chown=" + elif not part.startswith("--"): + non_flag_parts.append(part) + + if len(non_flag_parts) >= 2: + dest = non_flag_parts[-1] # Last part is destination + sources = non_flag_parts[:-1] + + for src in sources: + template_builder.copy(src, dest, user=user) + + +def _handle_workdir_instruction( + value: str, template_builder: DockerfileParserInterface +) -> None: + """Handle WORKDIR instruction""" + if not value.strip(): + return + workdir = value.strip() + template_builder.set_workdir(workdir) + + +def _handle_user_instruction( + value: str, template_builder: DockerfileParserInterface +) -> None: + """Handle USER instruction""" + if not value.strip(): + return + user = value.strip() + template_builder.set_user(user) + + +def _handle_env_instruction( + value: str, instruction_type: str, template_builder: DockerfileParserInterface +) -> None: + """Handle ENV/ARG instruction""" + if not value.strip(): + return + + # Parse environment variables from the value + # Handle both "KEY=value" and "KEY value" formats + env_vars = {} + + # First try to split on = for KEY=value format + if "=" in value: + # Handle multiple KEY=value pairs on one line + pairs = re.findall(r"(\w+)=([^\s]*(?:\s+(?!\w+=)[^\s]*)*)", value) + for key, val in pairs: + env_vars[key] = val.strip("\"'") + else: + # Handle "KEY value" format + parts = value.split(None, 1) + if len(parts) == 2: + key, val = parts + env_vars[key] = val.strip("\"'") + elif len(parts) == 1 and instruction_type == "ARG": + # ARG without default value + key = parts[0] + env_vars[key] = "" + + # Add each environment variable + if env_vars: + template_builder.set_envs(env_vars) + + +def _handle_cmd_entrypoint_instruction( + value: str, template_builder: DockerfileParserInterface +) -> None: + """Handle CMD/ENTRYPOINT instruction - convert to set_start_cmd with 20s timeout""" + if not value.strip(): + return + command = value.strip() + + # Try to parse as JSON (for array format like CMD ["sleep", "infinity"]) + try: + parsed_command = json.loads(command) + if isinstance(parsed_command, list): + command = " ".join(str(item) for item in parsed_command) + except Exception: + pass + + # Import wait_for_timeout locally to avoid circular dependency + def wait_for_timeout(timeout: int) -> str: + # convert to seconds, but ensure minimum of 1 second + seconds = max(1, timeout // 1000) + return f"sleep {seconds}" + + template_builder.set_start_cmd(command, wait_for_timeout(20_000)) diff --git a/packages/python-sdk/e2b/template/logger.py b/packages/python-sdk/e2b/template/logger.py new file mode 100644 index 0000000..414e049 --- /dev/null +++ b/packages/python-sdk/e2b/template/logger.py @@ -0,0 +1,232 @@ +import sys +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional, TypedDict, Callable, Dict, Literal + +from rich.console import Console +from rich.style import Style +from rich.text import Text + +from e2b.template.utils import strip_ansi_escape_codes + +"""Log entry severity levels.""" +LogEntryLevel = Literal["debug", "info", "warn", "error"] + + +@dataclass +class LogEntry: + """ + Represents a single log entry from the template build process. + """ + + timestamp: datetime + level: LogEntryLevel + message: str + + def __post_init__(self): + self.message = strip_ansi_escape_codes(self.message) + + def __str__(self) -> str: + return f"[{self.timestamp.isoformat()}] [{self.level}] {self.message}" + + +@dataclass +class LogEntryStart(LogEntry): + """ + Special log entry indicating the start of a build process. + """ + + level: LogEntryLevel = field(default="debug", init=False) + + +@dataclass +class LogEntryEnd(LogEntry): + """ + Special log entry indicating the end of a build process. + """ + + level: LogEntryLevel = field(default="debug", init=False) + + +""" +Interval in milliseconds for updating the build timer display. +""" +TIMER_UPDATE_INTERVAL_MS = 150 + +""" +Default minimum log level to display. +""" +DEFAULT_LEVEL: LogEntryLevel = "info" + +""" +Colored labels for each log level. +""" +levels: Dict[LogEntryLevel, tuple[str, Style]] = { + "error": ("ERROR", Style(color="red")), + "warn": ("WARN ", Style(color="#FF4400")), + "info": ("INFO ", Style(color="#FF8800")), + "debug": ("DEBUG", Style(color="bright_black")), +} + +""" +Numeric ordering of log levels for comparison (lower = less severe). +""" +level_order = { + "debug": 0, + "info": 1, + "warn": 2, + "error": 3, +} + + +def set_interval(func, interval): + """ + Returns a stop function that can be called to cancel the interval. + + Similar to JavaScript's setInterval. + + :param func: Function to execute at each interval + :param interval: Interval duration in **seconds** + + :return: Stop function that can be called to cancel the interval + """ + stopped = threading.Event() + + def loop(): + while not stopped.is_set(): + if stopped.wait(interval): # wait returns True if stopped + break + if not stopped.is_set(): # Double-check before executing + func() + + threading.Thread(target=loop, daemon=True).start() + return stopped.set # Return the stop function + + +class DefaultBuildLoggerInitialState(TypedDict): + start_time: float + animation_frame: int + timer: Optional[Callable[[], None]] + + +class DefaultBuildLogger: + __console = Console() + + __min_level: LogEntryLevel + __state: DefaultBuildLoggerInitialState + + def __init__(self, min_level: Optional[LogEntryLevel] = None): + self.__min_level = min_level if min_level is not None else DEFAULT_LEVEL + self.__reset_initial_state() + + def logger(self, log): + if isinstance(log, LogEntryStart): + self.__start_timer() + return + + if isinstance(log, LogEntryEnd): + if self.__state["timer"] is not None: + self.__state["timer"]() + return + + # Filter by minimum level + if level_order[log.level] < level_order[self.__min_level]: + return + + formatted_line = self.__format_log_line(log) + self.__console.print(formatted_line) + + # Redraw the timer line + self.__update_timer() + + def __reset_initial_state(self, timer: Optional[Callable[[], None]] = None): + self.__state = { + "start_time": time.time(), + "animation_frame": 0, + "timer": timer, + } + + def __format_timer_line(self) -> str: + elapsed_seconds = time.time() - self.__state["start_time"] + return f"{elapsed_seconds:.1f}s" + + def __animate_status(self) -> str: + frames = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"] + idx = self.__state["animation_frame"] % len(frames) + return frames[idx] + + def __format_log_line(self, line: LogEntry) -> Text: + timer = self.__format_timer_line().ljust(5) + timestamp = line.timestamp.strftime("%H:%M:%S") + level_text, level_style = levels.get(line.level, levels[DEFAULT_LEVEL]) + + # Build a rich Text object + text = Text.assemble( + timer, + " | ", + (timestamp, "dim"), + " ", + (level_text, level_style), + " ", + line.message, + ) + + return text + + def __start_timer(self): + if not sys.stdout.isatty(): + return + + # Start the timer interval + stop_timer = set_interval( + self.__update_timer, TIMER_UPDATE_INTERVAL_MS / 1000.0 + ) + + self.__reset_initial_state(stop_timer) + + # Initial timer display + self.__update_timer() + + def __update_timer(self): + if not sys.stdout.isatty(): + return + + self.__state["animation_frame"] += 1 + jumping_squares = self.__animate_status() + + timer_text = Text.assemble( + jumping_squares, " Building ", self.__format_timer_line() + ) + + # Print with carriage return + self.__console.print(timer_text, end="\r") + + +def default_build_logger( + min_level: Optional[LogEntryLevel] = None, +) -> Callable[[LogEntry], None]: + """ + Create a default build logger with animated timer display. + + :param min_level: Minimum log level to display (default: 'info') + + :return: Logger function that accepts LogEntry instances + + Example + ```python + from e2b import Template, default_build_logger + + template = Template().from_python_image() + + # Use with build - implementation would be in build_async module + # await Template.build(template, + # alias='my-template', + # on_build_logs=default_build_logger(min_level='debug') + # ) + ``` + """ + build_logger = DefaultBuildLogger(min_level) + + return build_logger.logger diff --git a/packages/python-sdk/e2b/template/main.py b/packages/python-sdk/e2b/template/main.py new file mode 100644 index 0000000..8b75642 --- /dev/null +++ b/packages/python-sdk/e2b/template/main.py @@ -0,0 +1,1368 @@ +import json +import shlex +from typing import Dict, List, Optional, Union, Literal +from pathlib import Path + + +from e2b.exceptions import BuildException, InvalidArgumentException +from e2b.template.consts import STACK_TRACE_DEPTH, RESOLVE_SYMLINKS +from e2b.template.dockerfile_parser import parse_dockerfile +from e2b.template.readycmd import ReadyCmd, wait_for_file +from e2b.template.types import ( + CopyItem, + Instruction, + TemplateType, + RegistryConfig, + InstructionType, +) +from e2b.template.utils import ( + calculate_files_hash, + get_caller_directory, + make_traceback, + pad_octal, + read_dockerignore, + read_gcp_service_account_json, + get_caller_frame, + validate_relative_path, +) +from types import TracebackType + + +class TemplateBuilder: + """ + Builder class for adding instructions to an E2B template. + + All methods return self to allow method chaining. + """ + + def __init__(self, template: "TemplateBase"): + self._template = template + + def copy( + self, + src: Union[Union[str, Path], List[Union[str, Path]]], + dest: Union[str, Path], + force_upload: Optional[Literal[True]] = None, + user: Optional[str] = None, + mode: Optional[int] = None, + resolve_symlinks: Optional[bool] = None, + gzip: Optional[bool] = None, + ) -> "TemplateBuilder": + """ + Copy files or directories from the local filesystem into the template. + + :param src: Source file(s) or directory path(s) to copy + :param dest: Destination path in the template + :param force_upload: Force upload even if files are cached + :param user: User and optionally group (user:group) to own the files + :param mode: File permissions in octal format (e.g., 0o755) + :param resolve_symlinks: Whether to resolve symlinks + :param gzip: Whether to gzip the files before upload (default: True) + + :return: `TemplateBuilder` class + + Example + ```python + template.copy('requirements.txt', '/home/user/') + template.copy(['app.py', 'config.py'], '/app/', mode=0o755) + ``` + """ + srcs = [src] if isinstance(src, (str, Path)) else src + + # Get the caller frame for stack trace in validation errors + caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) + stack_trace = make_traceback(caller_frame) + + for src_item in srcs: + src_string = str(src_item) + + # Validate that the source path is a relative path within the context directory + validate_relative_path(src_string, stack_trace) + + args = [ + src_string, + str(dest), + user or "", + pad_octal(mode) if mode else "", + ] + + instruction: Instruction = { + "type": InstructionType.COPY, + "args": args, + "force": force_upload or self._template._force_next_layer, + "forceUpload": force_upload, + "resolveSymlinks": resolve_symlinks, + "gzip": gzip, + } + + self._template._instructions.append(instruction) + + # Collect one stack trace per pushed instruction so build steps + # stay aligned with their stack traces when copying multiple + # sources + self._template._collect_stack_trace() + + return self + + def copy_items(self, items: List[CopyItem]) -> "TemplateBuilder": + """ + Copy multiple files or directories using a list of copy items. + + :param items: List of CopyItem dictionaries with src, dest, and optional parameters + + :return: `TemplateBuilder` class + + Example + ```python + template.copy_items([ + {'src': 'app.py', 'dest': '/app/'}, + {'src': 'config.py', 'dest': '/app/', 'mode': 0o644} + ]) + ``` + """ + # Get the stack trace at the copy_items call site + caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) + stack_trace = make_traceback(caller_frame) + + def _copy_items(): + for item in items: + try: + self.copy( + item["src"], + item["dest"], + item.get("forceUpload"), + item.get("user"), + item.get("mode"), + item.get("resolveSymlinks"), + item.get("gzip"), + ) + except Exception as error: + # Re-raise the error with the captured stack trace + if stack_trace is not None: + raise error.with_traceback(stack_trace) + raise + + # Use the override so each copied item collects this stack trace, + # keeping build steps aligned with their stack traces + self._template._run_in_stack_trace_override_context(_copy_items, stack_trace) + return self + + def remove( + self, + path: Union[Union[str, Path], List[Union[str, Path]]], + force: bool = False, + recursive: bool = False, + user: Optional[str] = None, + ) -> "TemplateBuilder": + """ + Remove files or directories in the template. + + :param path: File(s) or directory path(s) to remove + :param force: Force removal without prompting + :param recursive: Remove directories recursively + :param user: User to run the command as + + :return: `TemplateBuilder` class + + Example + ```python + template.remove('/tmp/cache', recursive=True, force=True) + template.remove('/tmp/cache', recursive=True, force=True, user='root') + ``` + """ + paths = [path] if isinstance(path, (str, Path)) else path + args = ["rm"] + if recursive: + args.append("-r") + if force: + args.append("-f") + args.extend([shlex.quote(str(p)) for p in paths]) + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(" ".join(args), user=user) + ) + + def rename( + self, + src: Union[str, Path], + dest: Union[str, Path], + force: bool = False, + user: Optional[str] = None, + ) -> "TemplateBuilder": + """ + Rename or move a file or directory in the template. + + :param src: Source path + :param dest: Destination path + :param force: Force rename without prompting + :param user: User to run the command as + + :return: `TemplateBuilder` class + + Example + ```python + template.rename('/tmp/old.txt', '/tmp/new.txt') + template.rename('/tmp/old.txt', '/tmp/new.txt', user='root') + ``` + """ + args = ["mv", shlex.quote(str(src)), shlex.quote(str(dest))] + if force: + args.append("-f") + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(" ".join(args), user=user) + ) + + def make_dir( + self, + path: Union[Union[str, Path], List[Union[str, Path]]], + mode: Optional[int] = None, + user: Optional[str] = None, + ) -> "TemplateBuilder": + """ + Create directory(ies) in the template. + + :param path: Directory path(s) to create + :param mode: Directory permissions in octal format (e.g., 0o755) + :param user: User to run the command as + + :return: `TemplateBuilder` class + + Example + ```python + template.make_dir('/app/data', mode=0o755) + template.make_dir(['/app/logs', '/app/cache']) + template.make_dir('/app/data', mode=0o755, user='root') + ``` + """ + path_list = [path] if isinstance(path, (str, Path)) else path + args = ["mkdir", "-p"] + if mode: + args.append(f"-m {pad_octal(mode)}") + args.extend([shlex.quote(str(p)) for p in path_list]) + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(" ".join(args), user=user) + ) + + def make_symlink( + self, + src: Union[str, Path], + dest: Union[str, Path], + user: Optional[str] = None, + force: bool = False, + ) -> "TemplateBuilder": + """ + Create a symbolic link in the template. + + :param src: Source path (target of the symlink) + :param dest: Destination path (location of the symlink) + :param user: User to run the command as + :param force: Force symlink without prompting + + :return: `TemplateBuilder` class + + Example + ```python + template.make_symlink('/usr/bin/python3', '/usr/bin/python') + template.make_symlink('/usr/bin/python3', '/usr/bin/python', user='root') + template.make_symlink('/usr/bin/python3', '/usr/bin/python', force=True) + ``` + """ + args = ["ln", "-s"] + if force: + args.append("-f") + args.extend([shlex.quote(str(src)), shlex.quote(str(dest))]) + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(" ".join(args), user=user) + ) + + def run_cmd( + self, command: Union[str, List[str]], user: Optional[str] = None + ) -> "TemplateBuilder": + """ + Run a shell command during template build. + + :param command: Command string or list of commands to run (joined with &&) + :param user: User to run the command as + + :return: `TemplateBuilder` class + + Example + ```python + template.run_cmd('apt-get update') + template.run_cmd(['pip install numpy', 'pip install pandas']) + template.run_cmd('apt-get install vim', user='root') + ``` + """ + commands = [command] if isinstance(command, str) else command + args = [" && ".join(commands)] + + if user: + args.append(user) + + instruction: Instruction = { + "type": InstructionType.RUN, + "args": args, + "force": self._template._force_next_layer, + "forceUpload": None, + } + self._template._instructions.append(instruction) + self._template._collect_stack_trace() + return self + + def set_workdir(self, workdir: Union[str, Path]) -> "TemplateBuilder": + """ + Set the working directory for subsequent commands in the template. + + :param workdir: Path to set as the working directory + + :return: `TemplateBuilder` class + + Example + ```python + template.set_workdir('/app') + ``` + """ + instruction: Instruction = { + "type": InstructionType.WORKDIR, + "args": [str(workdir)], + "force": self._template._force_next_layer, + "forceUpload": None, + } + self._template._instructions.append(instruction) + self._template._collect_stack_trace() + return self + + def set_user(self, user: str) -> "TemplateBuilder": + """ + Set the user for subsequent commands in the template. + + :param user: Username to set + + :return: `TemplateBuilder` class + + Example + ```python + template.set_user('root') + ``` + """ + instruction: Instruction = { + "type": InstructionType.USER, + "args": [user], + "force": self._template._force_next_layer, + "forceUpload": None, + } + self._template._instructions.append(instruction) + self._template._collect_stack_trace() + return self + + def pip_install( + self, packages: Optional[Union[str, List[str]]] = None, g: bool = True + ) -> "TemplateBuilder": + """ + Install Python packages using pip. + + :param packages: Package name(s) to install. If None, runs 'pip install .' in the current directory + :param g: Install packages globally (default: True). If False, installs for user only + + :return: `TemplateBuilder` class + + Example + ```python + template.pip_install('numpy') + template.pip_install(['pandas', 'scikit-learn']) + template.pip_install('numpy', g=False) # Install for user only + template.pip_install() # Installs from current directory + ``` + """ + if isinstance(packages, str): + packages = [packages] + + args = ["pip", "install"] + if not g: + args.append("--user") + if packages: + args.extend(packages) + else: + args.append(".") + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(" ".join(args), user="root" if g else None) + ) + + def npm_install( + self, + packages: Optional[Union[str, List[str]]] = None, + g: Optional[bool] = False, + dev: Optional[bool] = False, + ) -> "TemplateBuilder": + """ + Install Node.js packages using npm. + + :param packages: Package name(s) to install. If None, installs from package.json + :param g: Install packages globally + :param dev: Install packages as dev dependencies + + :return: `TemplateBuilder` class + + Example + ```python + template.npm_install('express') + template.npm_install(['lodash', 'axios']) + template.npm_install('typescript', g=True) + template.npm_install() # Installs from package.json + ``` + """ + if isinstance(packages, str): + packages = [packages] + + args = ["npm", "install"] + if g: + args.append("-g") + if dev: + args.append("--save-dev") + if packages: + args.extend(packages) + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(" ".join(args), user="root" if g else None) + ) + + def bun_install( + self, + packages: Optional[Union[str, List[str]]] = None, + g: Optional[bool] = False, + dev: Optional[bool] = False, + ) -> "TemplateBuilder": + """ + Install Bun packages using bun. + + :param packages: Package name(s) to install. If None, installs from package.json + :param g: Install packages globally + :param dev: Install packages as dev dependencies + + :return: `TemplateBuilder` class + + Example + ```python + template.bun_install('express') + template.bun_install(['lodash', 'axios']) + template.bun_install('tsx', g=True) + template.bun_install('typescript', dev=True) + template.bun_install() // Installs from package.json + ``` + """ + if isinstance(packages, str): + packages = [packages] + + args = ["bun", "install"] + if g: + args.append("-g") + if dev: + args.append("--dev") + if packages: + args.extend(packages) + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(" ".join(args), user="root" if g else None) + ) + + def apt_install( + self, + packages: Union[str, List[str]], + no_install_recommends: bool = False, + fix_missing: bool = False, + ) -> "TemplateBuilder": + """ + Install system packages using apt-get. + + :param packages: Package name(s) to install + :param no_install_recommends: Whether to install recommended packages + :param fix_missing: Whether to fix missing packages + + :return: `TemplateBuilder` class + + Example + ```python + template.apt_install('vim') + template.apt_install(['git', 'curl', 'wget']) + template.apt_install('vim', fix_missing=True) + ``` + """ + if isinstance(packages, str): + packages = [packages] + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd( + [ + "apt-get update", + f"DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y {'--no-install-recommends ' if no_install_recommends else ''}{'--fix-missing ' if fix_missing else ''}{' '.join(packages)}", + ], + user="root", + ) + ) + + def add_mcp_server(self, servers: Union[str, List[str]]) -> "TemplateBuilder": + """ + Install MCP servers using mcp-gateway. + + Note: Requires a base image with mcp-gateway pre-installed (e.g., mcp-gateway). + + :param servers: MCP server name(s) + + :return: `TemplateBuilder` class + + Example + ```python + template.add_mcp_server('exa') + template.add_mcp_server(['brave', 'firecrawl', 'duckduckgo']) + ``` + """ + if self._template._base_template != "mcp-gateway": + caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) + stack_trace = make_traceback(caller_frame) + raise BuildException( + "MCP servers can only be added to mcp-gateway template" + ).with_traceback(stack_trace) + + if isinstance(servers, str): + servers = [servers] + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(f"mcp-gateway pull {' '.join(servers)}", user="root") + ) + + def git_clone( + self, + url: str, + path: Optional[Union[str, Path]] = None, + branch: Optional[str] = None, + depth: Optional[int] = None, + user: Optional[str] = None, + ) -> "TemplateBuilder": + """ + Clone a git repository into the template. + + :param url: Git repository URL + :param path: Destination path for the clone + :param branch: Branch to clone + :param depth: Clone depth for shallow clones + :param user: User to run the command as + + :return: `TemplateBuilder` class + + Example + ```python + template.git_clone('https://github.com/user/repo.git', '/app/repo') + template.git_clone('https://github.com/user/repo.git', branch='main', depth=1) + template.git_clone('https://github.com/user/repo.git', '/app/repo', user='root') + ``` + """ + args = ["git", "clone", shlex.quote(url)] + if branch: + args.append(f"--branch {shlex.quote(branch)}") + args.append("--single-branch") + if depth: + args.append(f"--depth {depth}") + if path: + args.append(shlex.quote(str(path))) + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(" ".join(args), user=user) + ) + + def beta_dev_container_prebuild( + self, + devcontainer_directory: Union[str, Path], + ) -> "TemplateBuilder": + """ + Prebuild a devcontainer from the specified directory during the build process. + + :param devcontainer_directory: Path to the devcontainer directory + + :return: `TemplateBuilder` class + + Example + ```python + template.git_clone('https://myrepo.com/project.git', '/my-devcontainer') + template.beta_dev_container_prebuild('/my-devcontainer') + ``` + """ + if self._template._base_template != "devcontainer": + caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) + stack_trace = make_traceback(caller_frame) + raise BuildException( + "Devcontainers can only used in the devcontainer template" + ).with_traceback(stack_trace) + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd( + f"devcontainer build --workspace-folder {shlex.quote(str(devcontainer_directory))}", + user="root", + ) + ) + + def beta_set_dev_container_start( + self, + devcontainer_directory: Union[str, Path], + ) -> "TemplateFinal": + """ + Start a devcontainer from the specified directory and set it as the start command. + + This method returns `TemplateFinal`, which means it must be the last method in the chain. + + :param devcontainer_directory: Path to the devcontainer directory + + :return: `TemplateFinal` class + + Example + ```python + # Simple start + template.git_clone('https://myrepo.com/project.git', '/my-devcontainer') + template.beta_set_devcontainer_start('/my-devcontainer') + + # With prebuild + template.git_clone('https://myrepo.com/project.git', '/my-devcontainer') + template.beta_dev_container_prebuild('/my-devcontainer') + template.beta_set_dev_container_start('/my-devcontainer') + ``` + """ + if self._template._base_template != "devcontainer": + caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) + stack_trace = make_traceback(caller_frame) + raise BuildException( + "Devcontainers can only used in the devcontainer template" + ).with_traceback(stack_trace) + + def _set_start(): + dir_ = shlex.quote(str(devcontainer_directory)) + return self.set_start_cmd( + "sudo devcontainer up --workspace-folder " + + dir_ + + " && sudo /prepare-exec.sh " + + dir_ + + " | sudo tee /devcontainer.sh > /dev/null && sudo chmod +x /devcontainer.sh && sudo touch /devcontainer.up", + wait_for_file("/devcontainer.up"), + ) + + return self._template._run_in_new_stack_trace_context(_set_start) + + def set_envs(self, envs: Dict[str, str]) -> "TemplateBuilder": + """ + Set environment variables. + Note: Environment variables defined here are available only during template build. + + :param envs: Dictionary of environment variable names and values + + :return: `TemplateBuilder` class + + Example + ```python + template.set_envs({'NODE_ENV': 'production', 'PORT': '8080'}) + ``` + """ + if len(envs) == 0: + return self + + instruction: Instruction = { + "type": InstructionType.ENV, + "args": [item for key, value in envs.items() for item in [key, value]], + "force": self._template._force_next_layer, + "forceUpload": None, + } + self._template._instructions.append(instruction) + self._template._collect_stack_trace() + return self + + def skip_cache(self) -> "TemplateBuilder": + """ + Skip cache for all subsequent build instructions from this point. + + Call this before any instruction to force it and all following layers + to be rebuilt, ignoring any cached layers. + + :return: `TemplateBuilder` class + + Example + ```python + template.skip_cache().run_cmd('apt-get update') + ``` + """ + self._template._force_next_layer = True + return self + + def set_start_cmd( + self, start_cmd: str, ready_cmd: Union[str, ReadyCmd] + ) -> "TemplateFinal": + """ + Set the command to start when the sandbox launches and the ready check command. + + :param start_cmd: Command to run when the sandbox starts + :param ready_cmd: Command or ReadyCmd to check if the sandbox is ready + + :return: `TemplateFinal` class + + Example + ```python + # Using a string command + template.set_start_cmd( + 'python app.py', + 'curl http://localhost:8000/health' + ) + + # Using ReadyCmd helpers + from e2b import wait_for_port, wait_for_url + + template.set_start_cmd( + 'python -m http.server 8000', + wait_for_port(8000) + ) + + template.set_start_cmd( + 'npm start', + wait_for_url('http://localhost:3000/health', 200) + ) + ``` + """ + self._template._start_cmd = start_cmd + + if isinstance(ready_cmd, ReadyCmd): + ready_cmd = ready_cmd.get_cmd() + + self._template._ready_cmd = ready_cmd + self._template._collect_stack_trace() + return TemplateFinal(self._template) + + def set_ready_cmd(self, ready_cmd: Union[str, ReadyCmd]) -> "TemplateFinal": + """ + Set the command to check if the sandbox is ready. + + :param ready_cmd: Command or ReadyCmd to check if the sandbox is ready + + :return: `TemplateFinal` class + + Example + ```python + # Using a string command + template.set_ready_cmd('curl http://localhost:8000/health') + + # Using ReadyCmd helpers + from e2b import wait_for_port, wait_for_file, wait_for_process + + template.set_ready_cmd(wait_for_port(3000)) + + template.set_ready_cmd(wait_for_file('/tmp/ready')) + + template.set_ready_cmd(wait_for_process('nginx')) + ``` + """ + if isinstance(ready_cmd, ReadyCmd): + ready_cmd = ready_cmd.get_cmd() + + self._template._ready_cmd = ready_cmd + self._template._collect_stack_trace() + return TemplateFinal(self._template) + + +class TemplateFinal: + """ + Final template state after start/ready commands are set. + """ + + def __init__(self, template: "TemplateBase"): + self._template = template + + +class TemplateBase: + """ + Base class for building E2B sandbox templates. + """ + + _logs_refresh_frequency = 0.2 + + def __init__( + self, + file_context_path: Optional[Union[str, Path]] = None, + file_ignore_patterns: Optional[List[str]] = None, + ): + """ + Create a new template builder instance. + + :param file_context_path: Base path for resolving relative file paths in copy operations + :param file_ignore_patterns: List of glob patterns to ignore when copying files + """ + self._default_base_image: str = "e2bdev/base" + self._base_image: Optional[str] = self._default_base_image + self._base_template: Optional[str] = None + self._registry_config: Optional[RegistryConfig] = None + self._start_cmd: Optional[str] = None + self._ready_cmd: Optional[str] = None + # Force the whole template to be rebuilt + self._force: bool = False + # Force the next layer to be rebuilt + self._force_next_layer: bool = False + self._instructions: List[Instruction] = [] + # If no file_context_path is provided, use the caller's directory + self._file_context_path = ( + file_context_path.as_posix() + if isinstance(file_context_path, Path) + else (file_context_path or get_caller_directory(STACK_TRACE_DEPTH) or ".") + ) + self._file_ignore_patterns: List[str] = file_ignore_patterns or [] + self._stack_traces: List[Union[TracebackType, None]] = [] + self._stack_traces_enabled: bool = True + self._stack_traces_override: Optional[Union[TracebackType, None]] = None + + def skip_cache(self) -> "TemplateBase": + """ + Skip cache for all subsequent build instructions from this point. + + :return: `TemplateBase` class + + Example + ```python + template.skip_cache().from_python_image('3.11') + ``` + """ + self._force_next_layer = True + return self + + def _collect_stack_trace( + self, stack_traces_depth: int = STACK_TRACE_DEPTH + ) -> "TemplateBase": + """ + Collect the current stack trace for debugging purposes. + + :param stack_traces_depth: Depth to traverse in the call stack + + :return: `TemplateBase` class + """ + if not self._stack_traces_enabled: + return self + + # Use the override if set, otherwise get the caller frame + if self._stack_traces_override is not None: + self._stack_traces.append(self._stack_traces_override) + return self + + stack = get_caller_frame(stack_traces_depth) + self._stack_traces.append(make_traceback(stack)) + return self + + def _disable_stack_trace(self) -> "TemplateBase": + """ + Temporarily disable stack trace collection. + + :return: `TemplateBase` class + """ + self._stack_traces_enabled = False + return self + + def _enable_stack_trace(self) -> "TemplateBase": + """ + Re-enable stack trace collection. + + :return: `TemplateBase` class + """ + self._stack_traces_enabled = True + return self + + def _run_in_new_stack_trace_context(self, fn): + """ + Execute a function in a clean stack trace context. + + :param fn: Function to execute + + :return: The result of the function + """ + self._disable_stack_trace() + try: + result = fn() + finally: + self._enable_stack_trace() + self._collect_stack_trace(STACK_TRACE_DEPTH + 1) + return result + + def _run_in_stack_trace_override_context( + self, fn, stack_trace_override: Optional[Union[TracebackType, None]] + ): + """ + Execute a function with a manual stack trace override. + + :param fn: Function to execute + :param stack_trace_override: Stack trace to use instead of auto-collecting + + :return: The result of the function + """ + self._stack_traces_override = stack_trace_override + try: + return fn() + finally: + self._stack_traces_override = None + + # Built-in image mixins + def from_debian_image(self, variant: str = "stable") -> TemplateBuilder: + """ + Start template from a Debian base image. + + :param variant: Debian image variant + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_debian_image('bookworm') + ``` + """ + return self._run_in_new_stack_trace_context( + lambda: self.from_image(f"debian:{variant}") + ) + + def from_ubuntu_image(self, variant: str = "latest") -> TemplateBuilder: + """ + Start template from an Ubuntu base image. + + :param variant: Ubuntu image variant (default: 'latest') + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_ubuntu_image('24.04') + ``` + """ + return self._run_in_new_stack_trace_context( + lambda: self.from_image(f"ubuntu:{variant}") + ) + + def from_python_image(self, version: str = "3") -> TemplateBuilder: + """ + Start template from a Python base image. + + :param version: Python version (default: '3') + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_python_image('3') + ``` + """ + return self._run_in_new_stack_trace_context( + lambda: self.from_image(f"python:{version}") + ) + + def from_node_image(self, variant: str = "lts") -> TemplateBuilder: + """ + Start template from a Node.js base image. + + :param variant: Node.js image variant (default: 'lts') + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_node_image('24') + ``` + """ + return self._run_in_new_stack_trace_context( + lambda: self.from_image(f"node:{variant}") + ) + + def from_bun_image(self, variant: str = "latest") -> TemplateBuilder: + """ + Start template from a Bun base image. + + :param variant: Bun image variant (default: 'latest') + + :return: `TemplateBuilder` class + """ + return self._run_in_new_stack_trace_context( + lambda: self.from_image(f"oven/bun:{variant}") + ) + + def from_base_image(self) -> TemplateBuilder: + """ + Start template from the E2B base image (e2bdev/base:latest). + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_base_image() + ``` + """ + return self._run_in_new_stack_trace_context( + lambda: self.from_image(self._default_base_image) + ) + + def from_image( + self, + image: str, + username: Optional[str] = None, + password: Optional[str] = None, + ) -> TemplateBuilder: + """ + Start template from a Docker image. + + :param image: Docker image name (e.g., 'ubuntu:24.04') + :param username: Username for private registry authentication + :param password: Password for private registry authentication + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_image('python:3') + + # With credentials (optional) + Template().from_image('myregistry.com/myimage:latest', username='user', password='pass') + ``` + """ + # Validate (and resolve the registry config) before mutating the builder. + if username is not None or password is not None: + if not username or not password: + caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) + stack_trace = make_traceback(caller_frame) + raise InvalidArgumentException( + "Both username and password are required when providing registry credentials" + ).with_traceback(stack_trace) + + self._registry_config = { + "type": "registry", + "username": username, + "password": password, + } + + self._base_image = image + self._base_template = None + + # If we should force the next layer and it's a FROM command, invalidate whole template + if self._force_next_layer: + self._force = True + + self._collect_stack_trace() + return TemplateBuilder(self) + + def from_template(self, template: str) -> TemplateBuilder: + """ + Start template from an existing E2B template. + + :param template: E2B template ID or alias + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_template('my-base-template') + ``` + """ + self._base_template = template + self._base_image = None + + # If we should force the next layer and it's a FROM command, invalidate whole template + if self._force_next_layer: + self._force = True + + self._collect_stack_trace() + return TemplateBuilder(self) + + def from_dockerfile(self, dockerfile_content_or_path: str) -> TemplateBuilder: + """ + Parse a Dockerfile and convert it to Template SDK format. + + :param dockerfile_content_or_path: Either the Dockerfile content as a string, or a path to a Dockerfile file + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_dockerfile('Dockerfile') + Template().from_dockerfile('FROM python:3\\nRUN pip install numpy') + ``` + """ + # Create a TemplateBuilder first to use its methods + builder = TemplateBuilder(self) + + # Get the caller frame to use for stack trace override + # -1 as we're going up the call stack from the parse_dockerfile function + caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) + stack_trace_override = make_traceback(caller_frame) + + # Parse the dockerfile using the builder as the interface + base_image = self._run_in_stack_trace_override_context( + lambda: parse_dockerfile(dockerfile_content_or_path, builder), + stack_trace_override, + ) + self._base_image = base_image + + # If we should force the next layer and it's a FROM command, invalidate whole template + if self._force_next_layer: + self._force = True + + self._collect_stack_trace() + return builder + + def from_aws_registry( + self, + image: str, + access_key_id: str, + secret_access_key: str, + region: str, + ) -> TemplateBuilder: + """ + Start template from an AWS ECR registry image. + + :param image: Docker image name from AWS ECR + :param access_key_id: AWS access key ID + :param secret_access_key: AWS secret access key + :param region: AWS region + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_aws_registry( + '123456789.dkr.ecr.us-west-2.amazonaws.com/myimage:latest', + access_key_id='AKIA...', + secret_access_key='...', + region='us-west-2' + ) + ``` + """ + self._base_image = image + self._base_template = None + + # Set the registry config if provided + self._registry_config = { + "type": "aws", + "awsAccessKeyId": access_key_id, + "awsSecretAccessKey": secret_access_key, + "awsRegion": region, + } + + # If we should force the next layer and it's a FROM command, invalidate whole template + if self._force_next_layer: + self._force = True + + self._collect_stack_trace() + return TemplateBuilder(self) + + def from_gcp_registry( + self, image: str, service_account_json: Union[str, dict] + ) -> TemplateBuilder: + """ + Start template from a GCP Artifact Registry or Container Registry image. + + :param image: Docker image name from GCP registry + :param service_account_json: Service account JSON string, dict, or path to JSON file + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_gcp_registry( + 'gcr.io/myproject/myimage:latest', + service_account_json='path/to/service-account.json' + ) + ``` + """ + self._base_image = image + self._base_template = None + + # Set the registry config if provided + self._registry_config = { + "type": "gcp", + "serviceAccountJson": read_gcp_service_account_json( + self._file_context_path, service_account_json + ), + } + + # If we should force the next layer and it's a FROM command, invalidate whole template + if self._force_next_layer: + self._force = True + + self._collect_stack_trace() + return TemplateBuilder(self) + + @staticmethod + def to_json(template: "TemplateClass") -> str: + """ + Convert a template to JSON representation. + + :param template: The template to convert (TemplateBuilder or TemplateFinal instance) + + :return: JSON string representation of the template + + Example + ```python + template = Template().from_python_image('3').copy('app.py', '/app/') + json_str = TemplateBase.to_json(template) + ``` + """ + return json.dumps( + template._template._serialize( + template._template._instructions_with_hashes() + ), + indent=2, + ) + + @staticmethod + def to_dockerfile(template: "TemplateClass") -> str: + """ + Convert a template to Dockerfile format. + + Note: Templates based on other E2B templates cannot be converted to Dockerfile. + + :param template: The template to convert (TemplateBuilder or TemplateFinal instance) + + :return: Dockerfile string representation + + :raises ValueError: If the template is based on another E2B template or has no base image + + Example + ```python + template = Template().from_python_image('3').copy('app.py', '/app/') + dockerfile = TemplateBase.to_dockerfile(template) + ``` + """ + if template._template._base_template is not None: + raise ValueError( + "Cannot convert template built from another template to Dockerfile. " + "Templates based on other templates can only be built using the E2B API." + ) + + if template._template._base_image is None: + raise ValueError("No base image specified for template") + + dockerfile = f"FROM {template._template._base_image}\n" + + for instruction in template._template._instructions: + if instruction["type"] == InstructionType.RUN: + dockerfile += f"RUN {instruction['args'][0]}\n" + continue + + if instruction["type"] == InstructionType.COPY: + dockerfile += ( + f"COPY {instruction['args'][0]} {instruction['args'][1]}\n" + ) + continue + + if instruction["type"] == InstructionType.ENV: + args = instruction["args"] + values = [] + for i in range(0, len(args), 2): + values.append(f"{args[i]}={args[i + 1]}") + dockerfile += f"ENV {' '.join(values)}\n" + continue + + dockerfile += ( + f"{instruction['type'].value} {' '.join(instruction['args'])}\n" + ) + + if template._template._start_cmd: + dockerfile += f"ENTRYPOINT {template._template._start_cmd}\n" + + return dockerfile + + def _instructions_with_hashes( + self, + ) -> List[Instruction]: + """ + Add file hashes to COPY instructions for cache invalidation. + + :return: Copy of instructions list with filesHash added to COPY instructions + """ + steps: List[Instruction] = [] + + for index, instruction in enumerate(self._instructions): + step: Instruction = { + "type": instruction["type"], + "args": instruction["args"], + "force": instruction["force"], + "forceUpload": instruction.get("forceUpload"), + "resolveSymlinks": instruction.get("resolveSymlinks"), + "gzip": instruction.get("gzip"), + } + + if instruction["type"] == InstructionType.COPY: + stack_trace = None + if index + 1 < len(self._stack_traces): + stack_trace = self._stack_traces[index + 1] + + args = instruction.get("args", []) + src = args[0] if len(args) > 0 else None + dest = args[1] if len(args) > 1 else None + if src is None or dest is None: + raise ValueError("Source path and destination path are required") + + resolve_symlinks = instruction.get("resolveSymlinks") + step["filesHash"] = calculate_files_hash( + src, + dest, + self._file_context_path, + [ + *self._file_ignore_patterns, + *read_dockerignore(self._file_context_path), + ], + resolve_symlinks + if resolve_symlinks is not None + else RESOLVE_SYMLINKS, + stack_trace, + ) + + steps.append(step) + + return steps + + def _serialize(self, steps: List[Instruction]) -> TemplateType: + """ + Serialize the template to the API request format. + + :param steps: List of build instructions with file hashes + + :return: Template data formatted for the API + """ + _steps: List[Instruction] = [] + + for _, instruction in enumerate(steps): + step: Instruction = { + "type": instruction.get("type"), + "args": instruction.get("args"), + "force": instruction.get("force"), + } + + files_hash = instruction.get("filesHash") + if files_hash is not None: + step["filesHash"] = files_hash + + force_upload = instruction.get("forceUpload") + if force_upload is not None: + step["forceUpload"] = force_upload + + _steps.append(step) + + template_data: TemplateType = { + "steps": _steps, + "force": self._force, + } + + if self._base_image is not None: + template_data["fromImage"] = self._base_image + + if self._base_template is not None: + template_data["fromTemplate"] = self._base_template + + if self._registry_config is not None: + template_data["fromImageRegistry"] = self._registry_config + + if self._start_cmd is not None: + template_data["startCmd"] = self._start_cmd + + if self._ready_cmd is not None: + template_data["readyCmd"] = self._ready_cmd + + return template_data + + +TemplateClass = Union[TemplateFinal, TemplateBuilder] diff --git a/packages/python-sdk/e2b/template/readycmd.py b/packages/python-sdk/e2b/template/readycmd.py new file mode 100644 index 0000000..7ab28d9 --- /dev/null +++ b/packages/python-sdk/e2b/template/readycmd.py @@ -0,0 +1,144 @@ +import shlex + + +class ReadyCmd: + """ + Wrapper class for ready check commands. + """ + + def __init__(self, cmd: str): + self.__cmd = cmd + + def get_cmd(self): + return self.__cmd + + +def wait_for_port(port: int): + """ + Wait for a port to be listening. + + Uses `ss` command to check if a port is open and listening. + + :param port: Port number to wait for + + :return: ReadyCmd that checks for the port + + Example + ```python + from e2b import Template, wait_for_port + + template = ( + Template() + .from_python_image() + .set_start_cmd('python -m http.server 8000', wait_for_port(8000)) + ) + ``` + """ + # Match the exact listening port via ss's source-port filter (so e.g. port + # 80 doesn't match 8080). ss exits 0 regardless of matches, so test for + # non-empty output to signal readiness. + cmd = f'[ -n "$(ss -Htuln sport = :{port})" ]' + return ReadyCmd(cmd) + + +def wait_for_url(url: str, status_code: int = 200): + """ + Wait for a URL to return a specific HTTP status code. + + Uses `curl` to make HTTP requests and check the response status. + + :param url: URL to check (e.g., 'http://localhost:3000/health') + :param status_code: Expected HTTP status code (default: 200) + + :return: ReadyCmd that checks the URL + + Example + ```python + from e2b import Template, wait_for_url + + template = ( + Template() + .from_node_image() + .set_start_cmd('npm start', wait_for_url('http://localhost:3000/health')) + ) + ``` + """ + cmd = f'curl -s -o /dev/null -w "%{{http_code}}" {shlex.quote(url)} | grep -q "{status_code}"' + return ReadyCmd(cmd) + + +def wait_for_process(process_name: str): + """ + Wait for a process with a specific name to be running. + + Uses `pgrep` to check if a process exists. + + :param process_name: Name of the process to wait for + + :return: ReadyCmd that checks for the process + + Example + ```python + from e2b import Template, wait_for_process + + template = ( + Template() + .from_base_image() + .set_start_cmd('./my-daemon', wait_for_process('my-daemon')) + ) + ``` + """ + cmd = f"pgrep {shlex.quote(process_name)} > /dev/null" + return ReadyCmd(cmd) + + +def wait_for_file(filename: str): + """ + Wait for a file to exist. + + Uses shell test command to check file existence. + + :param filename: Path to the file to wait for + + :return: ReadyCmd that checks for the file + + Example + ```python + from e2b import Template, wait_for_file + + template = ( + Template() + .from_base_image() + .set_start_cmd('./init.sh', wait_for_file('/tmp/ready')) + ) + ``` + """ + cmd = f"[ -f {shlex.quote(filename)} ]" + return ReadyCmd(cmd) + + +def wait_for_timeout(timeout: int): + """ + Wait for a specified timeout before considering the sandbox ready. + + Uses `sleep` command to wait for a fixed duration. + + :param timeout: Time to wait in **milliseconds** (minimum: 1000ms / 1 second) + + :return: ReadyCmd that waits for the specified duration + + Example + ```python + from e2b import Template, wait_for_timeout + + template = ( + Template() + .from_node_image() + .set_start_cmd('npm start', wait_for_timeout(5000)) # Wait 5 seconds + ) + ``` + """ + # convert to seconds, but ensure minimum of 1 second + seconds = max(1, timeout // 1000) + cmd = f"sleep {seconds}" + return ReadyCmd(cmd) diff --git a/packages/python-sdk/e2b/template/types.py b/packages/python-sdk/e2b/template/types.py new file mode 100644 index 0000000..3f8c0d0 --- /dev/null +++ b/packages/python-sdk/e2b/template/types.py @@ -0,0 +1,194 @@ +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import List, Literal, Optional, TypedDict, Union + +from typing_extensions import NotRequired + +from e2b.template.logger import LogEntry + + +class TemplateBuildStatus(str, Enum): + """ + Status of a template build. + """ + + BUILDING = "building" + WAITING = "waiting" + READY = "ready" + ERROR = "error" + + +@dataclass +class BuildStatusReason: + """ + Reason for the current build status (typically for errors). + """ + + message: str + """Message with the status reason.""" + + step: Optional[str] = None + """Step that failed.""" + + log_entries: List[LogEntry] = field(default_factory=list) + """Log entries related to the status reason.""" + + +@dataclass +class TemplateBuildStatusResponse: + """ + Response from getting build status. + """ + + build_id: str + """Build identifier.""" + + template_id: str + """Template identifier.""" + + status: TemplateBuildStatus + """Current status of the build.""" + + log_entries: List[LogEntry] + """Build log entries.""" + + logs: List[str] + """Build logs (raw strings). Deprecated: use log_entries instead.""" + + reason: Optional[BuildStatusReason] = None + """Reason for the current status (typically for errors).""" + + +@dataclass +class TemplateTagInfo: + """ + Information about assigned template tags. + """ + + build_id: str + """Build identifier associated with this tag.""" + + tags: List[str] + """Assigned tags of the template.""" + + +@dataclass +class TemplateTag: + """ + Detailed information about a single template tag. + """ + + tag: str + """Name of the tag.""" + + build_id: str + """Build identifier associated with this tag.""" + + created_at: datetime + """When this tag was assigned.""" + + +class InstructionType(str, Enum): + """ + Types of instructions that can be used in a template. + """ + + COPY = "COPY" + ENV = "ENV" + RUN = "RUN" + WORKDIR = "WORKDIR" + USER = "USER" + + +class CopyItem(TypedDict): + """ + Configuration for a single file/directory copy operation. + """ + + src: Union[Union[str, Path], List[Union[str, Path]]] + dest: Union[str, Path] + forceUpload: NotRequired[Optional[Literal[True]]] + user: NotRequired[Optional[str]] + mode: NotRequired[Optional[int]] + resolveSymlinks: NotRequired[Optional[bool]] + gzip: NotRequired[Optional[bool]] + + +class Instruction(TypedDict): + """ + Represents a single instruction in the template build process. + """ + + type: InstructionType + args: List[str] + force: bool + forceUpload: NotRequired[Optional[Literal[True]]] + filesHash: NotRequired[Optional[str]] + resolveSymlinks: NotRequired[Optional[bool]] + gzip: NotRequired[Optional[bool]] + + +class GenericDockerRegistry(TypedDict): + """ + Configuration for a generic Docker registry with basic authentication. + """ + + type: Literal["registry"] + username: str + password: str + + +class AWSRegistry(TypedDict): + """ + Configuration for AWS Elastic Container Registry (ECR). + """ + + type: Literal["aws"] + awsAccessKeyId: str + awsSecretAccessKey: str + awsRegion: str + + +class GCPRegistry(TypedDict): + """ + Configuration for Google Container Registry (GCR) or Artifact Registry. + """ + + type: Literal["gcp"] + serviceAccountJson: str + + +""" +Union type for all supported container registry configurations. +""" +RegistryConfig = Union[GenericDockerRegistry, AWSRegistry, GCPRegistry] + + +class TemplateType(TypedDict): + """ + Internal representation of a template for the E2B build API. + """ + + fromImage: NotRequired[str] + fromTemplate: NotRequired[str] + fromImageRegistry: NotRequired[RegistryConfig] + startCmd: NotRequired[str] + readyCmd: NotRequired[str] + steps: List[Instruction] + force: bool + + +@dataclass +class BuildInfo: + """ + Information about a built template. + """ + + template_id: str + build_id: str + name: str + # Deprecated: use name instead + alias: str + tags: List[str] = field(default_factory=list) diff --git a/packages/python-sdk/e2b/template/utils.py b/packages/python-sdk/e2b/template/utils.py new file mode 100644 index 0000000..fc54936 --- /dev/null +++ b/packages/python-sdk/e2b/template/utils.py @@ -0,0 +1,426 @@ +import hashlib +import os +import tarfile +import tempfile +import json +import stat +from wcmatch import glob +import re +import inspect +from types import TracebackType, FrameType +from typing import IO, List, Optional, Union + +from e2b.exceptions import TemplateException +from e2b.template.consts import BASE_STEP_NAME, FINALIZE_STEP_NAME + + +def make_traceback(caller_frame: Optional[FrameType]) -> Optional[TracebackType]: + """ + Create a TracebackType from a caller frame for error reporting. + + :param caller_frame: The caller's frame object, or None + :return: A TracebackType object for use with exception.with_traceback(), or None + """ + if caller_frame is None: + return None + return TracebackType( + tb_next=None, + tb_frame=caller_frame, + tb_lasti=caller_frame.f_lasti, + tb_lineno=caller_frame.f_lineno, + ) + + +def validate_relative_path( + src: str, + stack_trace: Optional[TracebackType], +) -> None: + """ + Validate that a source path for copy operations is a relative path that stays + within the context directory. This prevents path traversal attacks and ensures + files are copied from within the expected directory. + + :param src: The source path to validate + :param stack_trace: Optional stack trace for error reporting + + :raises TemplateException: If the path is absolute or escapes the context directory + + Invalid paths: + - Absolute paths: /absolute/path, C:\\Windows\\path + - Parent directory escapes: ../foo, foo/../../bar, ./foo/../../../bar + + Valid paths: + - Simple relative: foo, foo/bar + - Current directory prefix: ./foo, ./foo/bar + - Internal parent refs that don't escape: foo/../bar (stays within context) + """ + # Check for absolute paths using Python's cross-platform implementation + if os.path.isabs(src): + raise TemplateException( + f'Invalid source path "{src}": absolute paths are not allowed. ' + "Use a relative path within the context directory." + ).with_traceback(stack_trace) + + # Normalize the path and check if it escapes the context directory + normalized = os.path.normpath(src) + + # After normalization, a path that escapes would be '..' or start with '../' + # We check for '..' followed by path separator to avoid false positives on filenames like '..myconfig' + # Examples: + # - '../foo' -> '../foo' (escapes) + # - 'foo/../../bar' -> '../bar' (escapes) + # - './foo/../../../bar' -> '../../bar' (escapes) + # - 'foo/../bar' -> 'bar' (doesn't escape) + # - './foo/bar' -> 'foo/bar' (doesn't escape) + # - '..myconfig' -> '..myconfig' (valid filename, doesn't escape) + escapes = normalized == ".." or normalized.startswith(".." + os.sep) + + if escapes: + raise TemplateException( + f'Invalid source path "{src}": path escapes the context directory. ' + "The path must stay within the context directory." + ).with_traceback(stack_trace) + + +def normalize_build_arguments( + name: Optional[str] = None, + alias: Optional[str] = None, +) -> str: + """ + Normalize build arguments from different parameter signatures. + Handles string name or legacy alias parameter. + + :param name: Template name in 'name' or 'name:tag' format + :param alias: (Deprecated) Alias name for the template. Use name instead. + :return: Normalized template name + :raises TemplateException: If no template name is provided + """ + if name and len(name) > 0: + return name + if alias and len(alias) > 0: + return alias + raise TemplateException("Name must be provided") + + +def read_dockerignore(context_path: str) -> List[str]: + """ + Read and parse a .dockerignore file. + + :param context_path: Directory path containing the .dockerignore file + + :return: Array of ignore patterns (empty lines and comments are filtered out) + """ + dockerignore_path = os.path.join(context_path, ".dockerignore") + if not os.path.exists(dockerignore_path): + return [] + + with open(dockerignore_path, "r", encoding="utf-8") as f: + content = f.read() + + return [ + line.strip() + for line in content.split("\n") + if line.strip() and not line.strip().startswith("#") + ] + + +def normalize_path(path: str) -> str: + """ + Normalize path separators to forward slashes for glob patterns (glob expects / even on Windows). + + :param path: The path to normalize + :return: The normalized path + """ + return path.replace(os.sep, "/") + + +def get_all_files_in_path( + src: str, + context_path: str, + ignore_patterns: List[str], + include_directories: bool = True, +) -> List[str]: + """ + Get all files for a given path and ignore patterns. + + :param src: Path to the source directory + :param context_path: Base directory for resolving relative paths + :param ignore_patterns: Ignore patterns + :param include_directories: Whether to include directories + :return: Array of files + """ + files = set() + + # Use glob to find all files/directories matching the pattern under context_path + abs_context_path = os.path.abspath(context_path) + files_glob = glob.glob( + src, + flags=glob.GLOBSTAR | glob.DOTMATCH, + root_dir=abs_context_path, + exclude=ignore_patterns, + ) + + for file in files_glob: + # Join it with abs_context_path to get the absolute path + file_path = os.path.join(abs_context_path, file) + + if os.path.isdir(file_path): + # If it's a directory, add the directory and all entries recursively + if include_directories: + files.add(file_path) + dir_files = glob.glob( + normalize_path(file) + "/**/*", + flags=glob.GLOBSTAR | glob.DOTMATCH, + root_dir=abs_context_path, + exclude=ignore_patterns, + ) + for dir_file in dir_files: + dir_file_path = os.path.join(abs_context_path, dir_file) + files.add(dir_file_path) + else: + files.add(file_path) + + return sorted(list(files)) + + +def calculate_files_hash( + src: str, + dest: str, + context_path: str, + ignore_patterns: List[str], + resolve_symlinks: bool, + stack_trace: Optional[TracebackType], +) -> str: + """ + Calculate a hash of files being copied to detect changes for cache invalidation. + + The hash includes file content, metadata (mode, size), and relative paths. + Note: uid, gid, and mtime are excluded to ensure stable hashes across environments. + + :param src: Source path pattern for files to copy + :param dest: Destination path where files will be copied + :param context_path: Base directory for resolving relative paths + :param ignore_patterns: Glob patterns to ignore + :param resolve_symlinks: Whether to resolve symbolic links when hashing + :param stack_trace: Optional stack trace for error reporting + + :return: Hex string hash of all files + + :raises ValueError: If no files match the source pattern + """ + src_path = os.path.join(context_path, src) + hash_obj = hashlib.sha256() + content = f"COPY {src} {dest}" + + hash_obj.update(content.encode()) + + files = get_all_files_in_path(src, context_path, ignore_patterns, True) + + if len(files) == 0: + raise ValueError(f"No files found in {src_path}").with_traceback(stack_trace) + + def hash_stats(stat_info: os.stat_result) -> None: + # Only include stable metadata (mode, size) + # Exclude uid, gid, and mtime to ensure consistent hashes across environments + hash_obj.update(str(stat_info.st_mode).encode()) + hash_obj.update(str(stat_info.st_size).encode()) + + for file in files: + # Hash the relative path + relative_path = os.path.relpath(file, context_path) + hash_obj.update(relative_path.encode()) + + # Add stat information to hash calculation + if os.path.islink(file): + stats = os.lstat(file) + should_follow = resolve_symlinks and ( + os.path.isfile(file) or os.path.isdir(file) + ) + + if not should_follow: + hash_stats(stats) + + content = os.readlink(file) + hash_obj.update(content.encode()) + continue + + stats = os.stat(file) + hash_stats(stats) + + if stat.S_ISREG(stats.st_mode): + with open(file, "rb") as f: + hash_obj.update(f.read()) + + return hash_obj.hexdigest() + + +def tar_file_stream( + file_name: str, + file_context_path: str, + ignore_patterns: List[str], + resolve_symlinks: bool, + gzip: bool, +) -> IO[bytes]: + """ + Create a tar archive of files matching a pattern in a temporary file. + + The archive is spooled to disk so it can be uploaded as a stream instead + of being buffered in memory. The temporary file is deleted when closed. + + :param file_name: Glob pattern for files to include + :param file_context_path: Base directory for resolving file paths + :param ignore_patterns: Ignore patterns + :param resolve_symlinks: Whether to resolve symbolic links + :param gzip: Whether to gzip the archive + + :return: Binary file object positioned at the start of the archive + """ + tar_file = tempfile.TemporaryFile() + try: + with tarfile.open( + fileobj=tar_file, + mode="w:gz" if gzip else "w", + dereference=resolve_symlinks, + ) as tar: + files = get_all_files_in_path( + file_name, file_context_path, ignore_patterns, True + ) + for file in files: + tar.add( + file, + arcname=os.path.relpath(file, file_context_path), + recursive=False, + ) + + tar_file.seek(0) + return tar_file + except Exception: + # Best-effort cleanup: a close failure must not replace the real + # archive-creation error. + try: + tar_file.close() + except Exception: + pass + raise + + +def strip_ansi_escape_codes(text: str) -> str: + """ + Strip ANSI escape codes from a string. + + Source: https://github.com/chalk/ansi-regex/blob/main/index.js + + :param text: String with ANSI escape codes + + :return: String without ANSI escape codes + """ + # Valid string terminator sequences are BEL, ESC\, and 0x9c + st = r"(?:\u0007|\u001B\u005C|\u009C)" + pattern = [ + rf"[\u001B\u009B][\[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?{st})", + r"(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))", + ] + ansi_escape = re.compile("|".join(pattern), re.UNICODE) + return ansi_escape.sub("", text) + + +def get_caller_frame(depth: int) -> Optional[FrameType]: + """ + Get the caller's stack frame at a specific depth. + + This is used to provide better error messages and debugging information + by tracking where template methods were called from in user code. + + :param depth: The depth of the stack trace to retrieve + + :return: The caller frame, or None if not available + """ + stack = inspect.stack()[1:] + if len(stack) < depth + 1: + return None + return stack[depth].frame + + +def get_caller_directory(depth: int) -> Optional[str]: + """ + Get the directory of the caller at a specific stack depth. + + This is used to determine the file_context_path when creating a template, + so file paths are resolved relative to the user's template file location. + + :param depth: The depth of the stack trace + + :return: The caller's directory path, or None if not available + """ + try: + # Get the stack trace + caller_frame = get_caller_frame(depth) + if caller_frame is None: + return None + + caller_file = caller_frame.f_code.co_filename + + # Return the directory of the caller file + return os.path.dirname(os.path.abspath(caller_file)) + except Exception: + return None + + +def pad_octal(mode: int) -> str: + """ + Convert a numeric file mode to a zero-padded octal string. + + :param mode: File mode as a number (e.g., 493 for 0o755) + + :return: Zero-padded 4-digit octal string (e.g., "0755") + + Example + ```python + pad_octal(0o755) # Returns "0755" + pad_octal(0o644) # Returns "0644" + ``` + """ + return f"{mode:04o}" + + +def get_build_step_index(step: str, stack_traces_length: int) -> int: + """ + Get the array index for a build step based on its name. + + Special steps: + - BASE_STEP_NAME: Returns 0 (first step) + - FINALIZE_STEP_NAME: Returns the last index + - Numeric strings: Converted to number + + :param step: Build step name or number as string + :param stack_traces_length: Total number of stack traces (used for FINALIZE_STEP_NAME) + + :return: Index for the build step + """ + if step == BASE_STEP_NAME: + return 0 + + if step == FINALIZE_STEP_NAME: + return stack_traces_length - 1 + + return int(step) + + +def read_gcp_service_account_json( + context_path: str, path_or_content: Union[str, dict] +) -> str: + """ + Read GCP service account JSON from a file or object. + + :param context_path: Base directory for resolving relative file paths + :param path_or_content: Either a path to a JSON file or a service account object + + :return: Service account JSON as a string + """ + if isinstance(path_or_content, str): + with open( + os.path.join(context_path, path_or_content), "r", encoding="utf-8" + ) as f: + return f.read() + else: + return json.dumps(path_or_content) diff --git a/packages/python-sdk/e2b/template_async/build_api.py b/packages/python-sdk/e2b/template_async/build_api.py new file mode 100644 index 0000000..c9209fa --- /dev/null +++ b/packages/python-sdk/e2b/template_async/build_api.py @@ -0,0 +1,419 @@ +import asyncio +import os +from types import TracebackType +from typing import Callable, Optional, List, Union + +import httpx + +from e2b.api import handle_api_exception +from e2b.io_utils import aiter_io_chunks +from e2b.api.client.api.templates import ( + post_v3_templates, + get_templates_template_id_files_hash, + post_v_2_templates_template_id_builds_build_id, + get_templates_template_id_builds_build_id_status, + get_templates_aliases_alias, +) +from e2b.api.client.api.tags import ( + post_templates_tags, + delete_templates_tags, + get_templates_template_id_tags, +) +from e2b.api.client.client import AuthenticatedClient +from e2b.api.client.models import ( + TemplateBuildRequestV3, + TemplateBuildStartV2, + TemplateBuildFileUpload, + Error, + AssignTemplateTagsRequest, + DeleteTemplateTagsRequest, +) +from e2b.api.client.types import UNSET, Unset +from e2b.exceptions import BuildException, FileUploadException, TemplateException +from e2b.template.logger import LogEntry +from e2b.template.types import ( + TemplateType, + BuildStatusReason, + TemplateBuildStatus, + TemplateBuildStatusResponse, + TemplateTag, + TemplateTagInfo, +) +from e2b.template.consts import FILE_UPLOAD_TIMEOUT_SECONDS +from e2b.template.utils import get_build_step_index, tar_file_stream + + +async def request_build( + client: AuthenticatedClient, + name: str, + tags: Optional[List[str]], + cpu_count: int, + memory_mb: int, +): + res = await post_v3_templates.asyncio_detailed( + client=client, + body=TemplateBuildRequestV3( + name=name, + tags=tags if tags else UNSET, + cpu_count=cpu_count, + memory_mb=memory_mb, + ), + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + if isinstance(res.parsed, Error): + raise BuildException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise BuildException("Failed to request build") + + return res.parsed + + +async def get_file_upload_link( + client: AuthenticatedClient, + template_id: str, + files_hash: str, + stack_trace: Optional[TracebackType] = None, +) -> TemplateBuildFileUpload: + res = await get_templates_template_id_files_hash.asyncio_detailed( + template_id=template_id, + hash_=files_hash, + client=client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, FileUploadException, stack_trace) + + if isinstance(res.parsed, Error): + raise FileUploadException(f"API error: {res.parsed.message}").with_traceback( + stack_trace + ) + + if res.parsed is None: + raise FileUploadException("Failed to get file upload link").with_traceback( + stack_trace + ) + + return res.parsed + + +async def upload_file( + api_client: AuthenticatedClient, + file_name: str, + context_path: str, + url: str, + ignore_patterns: List[str], + resolve_symlinks: bool, + gzip: bool, + stack_trace: Optional[TracebackType], + request_timeout: Optional[float] = None, +): + # Uploading a large build-context archive can take far longer than the 60s + # general API timeout, so default to a 1-hour upload timeout unless the + # caller set an explicit request_timeout. Matches the JS SDK + # (FILE_UPLOAD_TIMEOUT_MS). + upload_timeout = ( + request_timeout if request_timeout is not None else FILE_UPLOAD_TIMEOUT_SECONDS + ) + try: + tar_file = tar_file_stream( + file_name, context_path, ignore_patterns, resolve_symlinks, gzip + ) + try: + size = os.fstat(tar_file.fileno()).st_size + + async with httpx.AsyncClient( + timeout=httpx.Timeout(upload_timeout), + verify=api_client._verify_ssl, + follow_redirects=api_client._follow_redirects, + proxy=getattr(api_client, "_proxy", None), + http2=False, + ) as client: + # Stream the archive from disk via an async iterator. The + # explicit Content-Length suppresses chunked transfer + # encoding, which S3 presigned URLs reject. + response = await client.put( + url, + content=aiter_io_chunks(tar_file), + headers={"Content-Length": str(size)}, + ) + response.raise_for_status() + finally: + # Closing the spooled temp file is best-effort: a failure here + # must not mask a successful upload as a FileUploadException, + # nor overwrite a real upload error. + try: + tar_file.close() + except Exception: + pass + except httpx.HTTPStatusError as e: + raise FileUploadException(f"Failed to upload file: {e}").with_traceback( + stack_trace + ) + except Exception as e: + raise FileUploadException(f"Failed to upload file: {e}").with_traceback( + stack_trace + ) + + +async def trigger_build( + client: AuthenticatedClient, + template_id: str, + build_id: str, + template: TemplateType, +) -> None: + # Convert template dict to TemplateBuildStartV2 model using from_dict + template_data = TemplateBuildStartV2.from_dict(template) + + res = await post_v_2_templates_template_id_builds_build_id.asyncio_detailed( + template_id=template_id, + build_id=build_id, + client=client, + body=template_data, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + +def _map_log_entry(entry) -> LogEntry: + """Map API log entry to LogEntry type.""" + return LogEntry( + timestamp=entry.timestamp, + level=entry.level.value, + message=entry.message, + ) + + +def _map_build_status_reason(reason) -> Optional[BuildStatusReason]: + """Map API build status reason to custom BuildStatusReason type.""" + if reason is None or isinstance(reason, Unset): + return None + return BuildStatusReason( + message=reason.message, + step=reason.step if not isinstance(reason.step, Unset) else None, + log_entries=[ + _map_log_entry(e) + for e in ( + reason.log_entries + if not isinstance(reason.log_entries, Unset) and reason.log_entries + else [] + ) + ], + ) + + +async def get_build_status( + client: AuthenticatedClient, template_id: str, build_id: str, logs_offset: int +) -> TemplateBuildStatusResponse: + res = await get_templates_template_id_builds_build_id_status.asyncio_detailed( + template_id=template_id, + build_id=build_id, + client=client, + logs_offset=logs_offset, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + if isinstance(res.parsed, Error): + raise BuildException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise BuildException("Failed to get build status") + + return TemplateBuildStatusResponse( + build_id=res.parsed.build_id, + template_id=res.parsed.template_id, + status=TemplateBuildStatus(res.parsed.status.value), + log_entries=[_map_log_entry(e) for e in res.parsed.log_entries], + logs=res.parsed.logs, + reason=_map_build_status_reason(res.parsed.reason), + ) + + +async def wait_for_build_finish( + client: AuthenticatedClient, + template_id: str, + build_id: str, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + logs_refresh_frequency: float = 0.2, + stack_traces: List[Union[TracebackType, None]] = [], +): + logs_offset = 0 + status = TemplateBuildStatus.BUILDING + + async def poll_status() -> TemplateBuildStatusResponse: + nonlocal logs_offset + build_status = await get_build_status( + client, template_id, build_id, logs_offset + ) + + logs_offset += len(build_status.log_entries) + + for log_entry in build_status.log_entries: + if on_build_logs: + on_build_logs(log_entry) + + return build_status + + while status in [TemplateBuildStatus.BUILDING, TemplateBuildStatus.WAITING]: + build_status = await poll_status() + + status = build_status.status + + if status in [TemplateBuildStatus.READY, TemplateBuildStatus.ERROR]: + # The status endpoint returns at most 100 log entries per call, so + # the terminal response may not include the last logs - keep + # fetching until they are drained. + tail_status = build_status + while len(tail_status.log_entries) > 0: + tail_status = await poll_status() + + if status == TemplateBuildStatus.READY: + return + + traceback = None + if build_status.reason and build_status.reason.step: + # Find the corresponding stack trace for the failed step + step_index = get_build_step_index( + build_status.reason.step, len(stack_traces) + ) + if step_index < len(stack_traces): + traceback = stack_traces[step_index] + + raise BuildException( + build_status.reason.message if build_status.reason else "Build failed" + ).with_traceback(traceback) + + # Wait for a short period before checking the status again + await asyncio.sleep(logs_refresh_frequency) + + raise BuildException("Unknown build error occurred.") + + +async def check_alias_exists(client: AuthenticatedClient, alias: str) -> bool: + """ + Check if a template with the given alias exists. + + Args: + client: Authenticated API client + alias: Template alias to check + + Returns: + True if the alias exists, False otherwise + """ + res = await get_templates_aliases_alias.asyncio_detailed( + alias=alias, + client=client, + ) + + # If we get a NotFound, the alias doesn't exist + if res.status_code == 404: + return False + + # If we get a Forbidden, alias exists, but you are not owner + if res.status_code == 403: + return True + + # Handle other errors + if res.status_code >= 300: + raise handle_api_exception(res, TemplateException) + + # If we get Ok with data, you are owner and the alias exists + return res.parsed is not None + + +async def assign_tags( + client: AuthenticatedClient, target_name: str, tags: List[str] +) -> TemplateTagInfo: + """ + Assign tag(s) to an existing template build. + + Args: + client: Authenticated API client + target_name: Template name in 'name:tag' format (the source build to tag from) + tags: Tags to assign + + Returns: + TemplateTagInfo with build_id and assigned tags + """ + res = await post_templates_tags.asyncio_detailed( + client=client, + body=AssignTemplateTagsRequest( + target=target_name, + tags=tags, + ), + ) + + if res.status_code >= 300: + raise handle_api_exception(res, TemplateException) + + if isinstance(res.parsed, Error): + raise TemplateException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise TemplateException("Failed to assign tags") + + return TemplateTagInfo( + build_id=str(res.parsed.build_id), + tags=res.parsed.tags, + ) + + +async def remove_tags(client: AuthenticatedClient, name: str, tags: List[str]) -> None: + """ + Remove tag(s) from a template. + + Args: + client: Authenticated API client + name: Template name + tags: List of tags to remove + """ + res = await delete_templates_tags.asyncio_detailed( + client=client, + body=DeleteTemplateTagsRequest( + name=name, + tags=tags, + ), + ) + + if res.status_code >= 300: + raise handle_api_exception(res, TemplateException) + + +async def get_template_tags( + client: AuthenticatedClient, template_id: str +) -> List[TemplateTag]: + """ + Get all tags for a template. + + Args: + client: Authenticated API client + template_id: Template ID or name + """ + res = await get_templates_template_id_tags.asyncio_detailed( + template_id=template_id, + client=client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, TemplateException) + + if isinstance(res.parsed, Error): + raise TemplateException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise TemplateException("Failed to get template tags") + + return [ + TemplateTag( + tag=item.tag, + build_id=str(item.build_id), + created_at=item.created_at, + ) + for item in res.parsed + ] diff --git a/packages/python-sdk/e2b/template_async/main.py b/packages/python-sdk/e2b/template_async/main.py new file mode 100644 index 0000000..5c0a2c0 --- /dev/null +++ b/packages/python-sdk/e2b/template_async/main.py @@ -0,0 +1,528 @@ +from datetime import datetime +from typing import Callable, List, Optional, Union + +from typing_extensions import Unpack + +from e2b.api.client.client import AuthenticatedClient +from e2b.connection_config import ApiParams, ConnectionConfig +from e2b.template.consts import GZIP, RESOLVE_SYMLINKS +from e2b.template.logger import LogEntry, LogEntryEnd, LogEntryStart +from e2b.template.main import TemplateBase, TemplateClass +from e2b.template.types import BuildInfo, InstructionType, TemplateTag, TemplateTagInfo +from e2b.template.utils import normalize_build_arguments, read_dockerignore + +from .build_api import ( + assign_tags, + check_alias_exists, + get_template_tags, + remove_tags, + get_build_status, + get_file_upload_link, + request_build, + trigger_build, + upload_file, + wait_for_build_finish, +) +from e2b.api.client_async import get_api_client + + +class AsyncTemplate(TemplateBase): + """ + Asynchronous template builder for E2B sandboxes. + """ + + @staticmethod + async def _build( + api_client: AuthenticatedClient, + template: TemplateClass, + name: str, + tags: Optional[List[str]] = None, + cpu_count: int = 2, + memory_mb: int = 1024, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + request_timeout: Optional[float] = None, + ) -> BuildInfo: + """ + Internal implementation of the template build process + + :param api_client: Authenticated API client + :param template: The template to build + :param name: Name for the template + :param tags: Optional tags for the template + :param cpu_count: Number of CPUs allocated to the sandbox + :param memory_mb: Amount of memory in MB allocated to the sandbox + :param skip_cache: If True, forces a complete rebuild ignoring cache + :param on_build_logs: Callback function to receive build logs during the build process + """ + if skip_cache: + template._template._force = True + + # Create template + if on_build_logs: + tags_msg = f" with tags: {', '.join(tags)}" if tags else "" + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Requesting build for template: {name}{tags_msg}", + ) + ) + + response = await request_build( + api_client, + name=name, + cpu_count=cpu_count, + memory_mb=memory_mb, + tags=tags, + ) + + template_id = response.template_id + build_id = response.build_id + response_tags = response.tags + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Template created with ID: {template_id}, Build ID: {build_id}", + ) + ) + + instructions_with_hashes = template._template._instructions_with_hashes() + + # Upload files + for index, file_upload in enumerate(instructions_with_hashes): + if file_upload["type"] != InstructionType.COPY: + continue + + args = file_upload.get("args", []) + src = args[0] if len(args) > 0 else None + force_upload = file_upload.get("forceUpload") + files_hash = file_upload.get("filesHash", None) + resolve_symlinks = file_upload.get("resolveSymlinks") + if resolve_symlinks is None: + resolve_symlinks = RESOLVE_SYMLINKS + gzip = file_upload.get("gzip") + if gzip is None: + gzip = GZIP + + if src is None or files_hash is None: + raise ValueError("Source path and files hash are required") + + stack_trace = None + if index + 1 < len(template._template._stack_traces): + stack_trace = template._template._stack_traces[index + 1] + + file_info = await get_file_upload_link( + api_client, template_id, files_hash, stack_trace + ) + + if (force_upload and file_info.url) or ( + file_info.present is False and file_info.url + ): + await upload_file( + api_client, + src, + template._template._file_context_path, + file_info.url, + [ + *template._template._file_ignore_patterns, + *read_dockerignore(template._template._file_context_path), + ], + resolve_symlinks, + gzip, + stack_trace, + request_timeout=request_timeout, + ) + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Uploaded '{src}'", + ) + ) + else: + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Skipping upload of '{src}', already cached", + ) + ) + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="All file uploads completed", + ) + ) + + # Start build + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="Starting building...", + ) + ) + + await trigger_build( + api_client, + template_id, + build_id, + template._template._serialize(instructions_with_hashes), + ) + + return BuildInfo( + template_id=template_id, + build_id=build_id, + alias=name, + name=name, + tags=response_tags, + ) + + @staticmethod + async def build( + template: TemplateClass, + name: Optional[str] = None, + *, + alias: Optional[str] = None, + tags: Optional[List[str]] = None, + cpu_count: int = 2, + memory_mb: int = 1024, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + **opts: Unpack[ApiParams], + ) -> BuildInfo: + """ + Build and deploy a template to E2B infrastructure. + + :param template: The template to build + :param name: Template name in 'name' or 'name:tag' format + :param alias: (Deprecated) Alias name for the template. Use name instead. + :param tags: Optional additional tags to assign to the template + :param cpu_count: Number of CPUs allocated to the sandbox + :param memory_mb: Amount of memory in MB allocated to the sandbox + :param skip_cache: If True, forces a complete rebuild ignoring cache + :param on_build_logs: Callback function to receive build logs during the build process + + Example + ```python + from e2b import AsyncTemplate + + template = ( + AsyncTemplate() + .from_python_image('3') + .copy('requirements.txt', '/home/user/') + .run_cmd('pip install -r /home/user/requirements.txt') + ) + + # Build with single tag + await AsyncTemplate.build(template, 'my-python-env:v1.0') + + # Build with multiple tags + await AsyncTemplate.build(template, 'my-python-env', tags=['v1.1.0', 'stable']) + ``` + """ + name = normalize_build_arguments(name, alias) + + try: + if on_build_logs: + on_build_logs( + LogEntryStart( + timestamp=datetime.now(), + message="Build started", + ) + ) + + config = ConnectionConfig(**opts) + api_client = get_api_client( + config, + ) + + data = await AsyncTemplate._build( + api_client, + template, + name, + tags=tags, + cpu_count=cpu_count, + memory_mb=memory_mb, + skip_cache=skip_cache, + on_build_logs=on_build_logs, + # Only honor an explicitly set request_timeout for uploads; + # otherwise upload_file applies its 1-hour default. + request_timeout=opts.get("request_timeout"), + ) + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="Waiting for logs...", + ) + ) + + await wait_for_build_finish( + api_client, + data.template_id, + data.build_id, + on_build_logs, + logs_refresh_frequency=TemplateBase._logs_refresh_frequency, + stack_traces=template._template._stack_traces, + ) + + return data + finally: + if on_build_logs: + on_build_logs( + LogEntryEnd( + timestamp=datetime.now(), + message="Build finished", + ) + ) + + @staticmethod + async def build_in_background( + template: TemplateClass, + name: Optional[str] = None, + *, + alias: Optional[str] = None, + tags: Optional[List[str]] = None, + cpu_count: int = 2, + memory_mb: int = 1024, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + **opts: Unpack[ApiParams], + ) -> BuildInfo: + """ + Build and deploy a template to E2B infrastructure without waiting for completion. + + :param template: The template to build + :param name: Template name in 'name' or 'name:tag' format + :param alias: (Deprecated) Alias name for the template. Use name instead. + :param tags: Optional additional tags to assign to the template + :param cpu_count: Number of CPUs allocated to the sandbox + :param memory_mb: Amount of memory in MB allocated to the sandbox + :param skip_cache: If True, forces a complete rebuild ignoring cache + :return: BuildInfo containing the template ID and build ID + + Example + ```python + from e2b import AsyncTemplate + + template = ( + AsyncTemplate() + .from_python_image('3') + .run_cmd('echo "test"') + .set_start_cmd('echo "Hello"', 'sleep 1') + ) + + # Build with single tag + build_info = await AsyncTemplate.build_in_background(template, 'my-python-env:v1.0') + + # Build with multiple tags + build_info = await AsyncTemplate.build_in_background(template, 'my-python-env', tags=['v1.1.0', 'stable']) + ``` + """ + name = normalize_build_arguments(name, alias) + + config = ConnectionConfig(**opts) + api_client = get_api_client( + config, + ) + + return await AsyncTemplate._build( + api_client, + template, + name, + tags=tags, + cpu_count=cpu_count, + memory_mb=memory_mb, + skip_cache=skip_cache, + on_build_logs=on_build_logs, + # Only honor an explicitly set request_timeout for uploads; + # otherwise upload_file applies its 1-hour default. + request_timeout=opts.get("request_timeout"), + ) + + @staticmethod + async def get_build_status( + build_info: BuildInfo, + logs_offset: int = 0, + **opts: Unpack[ApiParams], + ): + """ + Get the status of a build. + + :param build_info: Build identifiers returned from build_in_background + :param logs_offset: Offset for fetching logs + :return: TemplateBuild containing the build status and logs + + Example + ```python + from e2b import AsyncTemplate + + build_info = await AsyncTemplate.build_in_background(template, alias='my-template') + status = await AsyncTemplate.get_build_status(build_info, logs_offset=0) + ``` + """ + config = ConnectionConfig(**opts) + api_client = get_api_client( + config, + ) + return await get_build_status( + api_client, + build_info.template_id, + build_info.build_id, + logs_offset, + ) + + @staticmethod + async def exists( + name: str, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Check if a template with the given name exists. + + :param name: Template name to check + :return: True if the name exists, False otherwise + + Example + ```python + from e2b import AsyncTemplate + + exists = await AsyncTemplate.exists('my-python-env') + if exists: + print('Template exists!') + ``` + """ + + return await AsyncTemplate.alias_exists(name, **opts) + + @staticmethod + async def alias_exists( + alias: str, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Check if a template with the given alias exists. + + Deprecated Use `exists` instead. + + :param alias: Template alias to check + :return: True if the alias exists, False otherwise + + Example + ```python + from e2b import AsyncTemplate + + exists = await AsyncTemplate.alias_exists('my-python-env') + if exists: + print('Template exists!') + ``` + """ + config = ConnectionConfig(**opts) + api_client = get_api_client( + config, + ) + + return await check_alias_exists(api_client, alias) + + @staticmethod + async def assign_tags( + target_name: str, + tags: Union[str, List[str]], + **opts: Unpack[ApiParams], + ) -> TemplateTagInfo: + """ + Assign tag(s) to an existing template build. + + :param target_name: Template name in 'name:tag' format (the source build to tag from) + :param tags: Tag or tags to assign + :return: TemplateTagInfo with build_id and assigned tags + + Example + ```python + from e2b import AsyncTemplate + + # Assign a single tag + result = await AsyncTemplate.assign_tags('my-template:v1.0', 'production') + + # Assign multiple tags + result = await AsyncTemplate.assign_tags('my-template:v1.0', ['production', 'stable']) + ``` + """ + config = ConnectionConfig(**opts) + api_client = get_api_client( + config, + ) + + normalized_tags = [tags] if isinstance(tags, str) else tags + return await assign_tags(api_client, target_name, normalized_tags) + + @staticmethod + async def remove_tags( + name: str, + tags: Union[str, List[str]], + **opts: Unpack[ApiParams], + ) -> None: + """ + Remove tag(s) from a template. + + :param name: Template name + :param tags: Tag or tags to remove + + Example + ```python + from e2b import AsyncTemplate + + # Remove a single tag + await AsyncTemplate.remove_tags('my-template', 'production') + + # Remove multiple tags + await AsyncTemplate.remove_tags('my-template', ['production', 'stable']) + ``` + """ + config = ConnectionConfig(**opts) + api_client = get_api_client( + config, + ) + + normalized_tags = [tags] if isinstance(tags, str) else tags + await remove_tags(api_client, name, normalized_tags) + + @staticmethod + async def get_tags( + template_id: str, + **opts: Unpack[ApiParams], + ) -> List[TemplateTag]: + """ + Get all tags for a template. + + :param template_id: Template ID or name + :return: List of TemplateTag with tag name, build_id, and created_at + + Example + ```python + from e2b import AsyncTemplate + + tags = await AsyncTemplate.get_tags('my-template') + for tag in tags: + print(f"Tag: {tag.tag}, Build: {tag.build_id}, Created: {tag.created_at}") + ``` + """ + config = ConnectionConfig(**opts) + api_client = get_api_client( + config, + ) + + return await get_template_tags(api_client, template_id) diff --git a/packages/python-sdk/e2b/template_sync/build_api.py b/packages/python-sdk/e2b/template_sync/build_api.py new file mode 100644 index 0000000..e838d87 --- /dev/null +++ b/packages/python-sdk/e2b/template_sync/build_api.py @@ -0,0 +1,409 @@ +import time +from types import TracebackType +from typing import Callable, Optional, List, Union + +import httpx + +from e2b.api import handle_api_exception +from e2b.api.client.api.templates import ( + post_v3_templates, + get_templates_template_id_files_hash, + post_v_2_templates_template_id_builds_build_id, + get_templates_template_id_builds_build_id_status, + get_templates_aliases_alias, +) +from e2b.api.client.api.tags import ( + post_templates_tags, + delete_templates_tags, + get_templates_template_id_tags, +) +from e2b.api.client.client import AuthenticatedClient +from e2b.api.client.models import ( + TemplateBuildRequestV3, + TemplateBuildStartV2, + TemplateBuildFileUpload, + Error, + AssignTemplateTagsRequest, + DeleteTemplateTagsRequest, +) +from e2b.api.client.types import UNSET, Unset +from e2b.exceptions import BuildException, FileUploadException, TemplateException +from e2b.template.logger import LogEntry +from e2b.template.types import ( + TemplateType, + BuildStatusReason, + TemplateBuildStatus, + TemplateBuildStatusResponse, + TemplateTag, + TemplateTagInfo, +) +from e2b.template.consts import FILE_UPLOAD_TIMEOUT_SECONDS +from e2b.template.utils import get_build_step_index, tar_file_stream + + +def request_build( + client: AuthenticatedClient, + name: str, + tags: Optional[List[str]], + cpu_count: int, + memory_mb: int, +): + res = post_v3_templates.sync_detailed( + client=client, + body=TemplateBuildRequestV3( + name=name, + tags=tags if tags else UNSET, + cpu_count=cpu_count, + memory_mb=memory_mb, + ), + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + if isinstance(res.parsed, Error): + raise BuildException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise BuildException("Failed to request build") + + return res.parsed + + +def get_file_upload_link( + client: AuthenticatedClient, + template_id: str, + files_hash: str, + stack_trace: Optional[TracebackType] = None, +) -> TemplateBuildFileUpload: + res = get_templates_template_id_files_hash.sync_detailed( + template_id=template_id, + hash_=files_hash, + client=client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, FileUploadException, stack_trace) + + if isinstance(res.parsed, Error): + raise FileUploadException(f"API error: {res.parsed.message}").with_traceback( + stack_trace + ) + + if res.parsed is None: + raise FileUploadException("Failed to get file upload link").with_traceback( + stack_trace + ) + + return res.parsed + + +def upload_file( + api_client: AuthenticatedClient, + file_name: str, + context_path: str, + url: str, + ignore_patterns: List[str], + resolve_symlinks: bool, + gzip: bool, + stack_trace: Optional[TracebackType], + request_timeout: Optional[float] = None, +): + # Uploading a large build-context archive can take far longer than the 60s + # general API timeout, so default to a 1-hour upload timeout unless the + # caller set an explicit request_timeout. Matches the JS SDK + # (FILE_UPLOAD_TIMEOUT_MS). + upload_timeout = ( + request_timeout if request_timeout is not None else FILE_UPLOAD_TIMEOUT_SECONDS + ) + try: + tar_file = tar_file_stream( + file_name, context_path, ignore_patterns, resolve_symlinks, gzip + ) + try: + with httpx.Client( + timeout=httpx.Timeout(upload_timeout), + verify=api_client._verify_ssl, + follow_redirects=api_client._follow_redirects, + proxy=getattr(api_client, "_proxy", None), + http2=False, + ) as client: + # httpx streams the archive from disk in chunks and sets + # Content-Length from the file size—S3 presigned URLs reject + # chunked transfer encoding. + response = client.put(url, content=tar_file) + response.raise_for_status() + finally: + # Closing the spooled temp file is best-effort: a failure here + # must not mask a successful upload as a FileUploadException, + # nor overwrite a real upload error. + try: + tar_file.close() + except Exception: + pass + except httpx.HTTPStatusError as e: + raise FileUploadException(f"Failed to upload file: {e}").with_traceback( + stack_trace + ) + except Exception as e: + raise FileUploadException(f"Failed to upload file: {e}").with_traceback( + stack_trace + ) + + +def trigger_build( + client: AuthenticatedClient, + template_id: str, + build_id: str, + template: TemplateType, +) -> None: + # Convert template dict to TemplateBuildStartV2 model using from_dict + template_data = TemplateBuildStartV2.from_dict(template) + + res = post_v_2_templates_template_id_builds_build_id.sync_detailed( + template_id=template_id, + build_id=build_id, + client=client, + body=template_data, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + +def _map_log_entry(entry) -> LogEntry: + """Map API log entry to LogEntry type.""" + return LogEntry( + timestamp=entry.timestamp, + level=entry.level.value, + message=entry.message, + ) + + +def _map_build_status_reason(reason) -> Optional[BuildStatusReason]: + """Map API build status reason to custom BuildStatusReason type.""" + if reason is None or isinstance(reason, Unset): + return None + return BuildStatusReason( + message=reason.message, + step=reason.step if not isinstance(reason.step, Unset) else None, + log_entries=[ + _map_log_entry(e) + for e in ( + reason.log_entries + if not isinstance(reason.log_entries, Unset) and reason.log_entries + else [] + ) + ], + ) + + +def get_build_status( + client: AuthenticatedClient, template_id: str, build_id: str, logs_offset: int +) -> TemplateBuildStatusResponse: + res = get_templates_template_id_builds_build_id_status.sync_detailed( + template_id=template_id, + build_id=build_id, + client=client, + logs_offset=logs_offset, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + if isinstance(res.parsed, Error): + raise BuildException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise BuildException("Failed to get build status") + + return TemplateBuildStatusResponse( + build_id=res.parsed.build_id, + template_id=res.parsed.template_id, + status=TemplateBuildStatus(res.parsed.status.value), + log_entries=[_map_log_entry(e) for e in res.parsed.log_entries], + logs=res.parsed.logs, + reason=_map_build_status_reason(res.parsed.reason), + ) + + +def wait_for_build_finish( + client: AuthenticatedClient, + template_id: str, + build_id: str, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + logs_refresh_frequency: float = 0.2, + stack_traces: List[Union[TracebackType, None]] = [], +): + logs_offset = 0 + status = TemplateBuildStatus.BUILDING + + def poll_status() -> TemplateBuildStatusResponse: + nonlocal logs_offset + build_status = get_build_status(client, template_id, build_id, logs_offset) + + logs_offset += len(build_status.log_entries) + + for log_entry in build_status.log_entries: + if on_build_logs: + on_build_logs(log_entry) + + return build_status + + while status in [TemplateBuildStatus.BUILDING, TemplateBuildStatus.WAITING]: + build_status = poll_status() + + status = build_status.status + + if status in [TemplateBuildStatus.READY, TemplateBuildStatus.ERROR]: + # The status endpoint returns at most 100 log entries per call, so + # the terminal response may not include the last logs - keep + # fetching until they are drained. + tail_status = build_status + while len(tail_status.log_entries) > 0: + tail_status = poll_status() + + if status == TemplateBuildStatus.READY: + return + + traceback = None + if build_status.reason and build_status.reason.step: + # Find the corresponding stack trace for the failed step + step_index = get_build_step_index( + build_status.reason.step, len(stack_traces) + ) + if step_index < len(stack_traces): + traceback = stack_traces[step_index] + + raise BuildException( + build_status.reason.message if build_status.reason else "Build failed" + ).with_traceback(traceback) + + # Wait for a short period before checking the status again + time.sleep(logs_refresh_frequency) + + raise BuildException("Unknown build error occurred.") + + +def check_alias_exists(client: AuthenticatedClient, alias: str) -> bool: + """ + Check if a template with the given alias exists. + + Args: + client: Authenticated API client + alias: Template alias to check + + Returns: + True if the alias exists, False otherwise + """ + res = get_templates_aliases_alias.sync_detailed( + alias=alias, + client=client, + ) + + # If we get a NotFound, the alias doesn't exist + if res.status_code == 404: + return False + + # If we get a Forbidden, alias exists, but you are not owner + if res.status_code == 403: + return True + + # Handle other errors + if res.status_code >= 300: + raise handle_api_exception(res, TemplateException) + + # If we get Ok with data, you are owner and the alias exists + return res.parsed is not None + + +def assign_tags( + client: AuthenticatedClient, target_name: str, tags: List[str] +) -> TemplateTagInfo: + """ + Assign tag(s) to an existing template build. + + Args: + client: Authenticated API client + target_name: Template name in 'name:tag' format (the source build to tag from) + tags: Tags to assign + + Returns: + TemplateTagInfo with build_id and assigned tags + """ + res = post_templates_tags.sync_detailed( + client=client, + body=AssignTemplateTagsRequest( + target=target_name, + tags=tags, + ), + ) + + if res.status_code >= 300: + raise handle_api_exception(res, TemplateException) + + if isinstance(res.parsed, Error): + raise TemplateException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise TemplateException("Failed to assign tags") + + return TemplateTagInfo( + build_id=str(res.parsed.build_id), + tags=res.parsed.tags, + ) + + +def remove_tags(client: AuthenticatedClient, name: str, tags: List[str]) -> None: + """ + Remove tag(s) from a template. + + Args: + client: Authenticated API client + name: Template name + tags: List of tags to remove + """ + res = delete_templates_tags.sync_detailed( + client=client, + body=DeleteTemplateTagsRequest( + name=name, + tags=tags, + ), + ) + + if res.status_code >= 300: + raise handle_api_exception(res, TemplateException) + + +def get_template_tags( + client: AuthenticatedClient, template_id: str +) -> List[TemplateTag]: + """ + Get all tags for a template. + + Args: + client: Authenticated API client + template_id: Template ID or name + """ + res = get_templates_template_id_tags.sync_detailed( + template_id=template_id, + client=client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, TemplateException) + + if isinstance(res.parsed, Error): + raise TemplateException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise TemplateException("Failed to get template tags") + + return [ + TemplateTag( + tag=item.tag, + build_id=str(item.build_id), + created_at=item.created_at, + ) + for item in res.parsed + ] diff --git a/packages/python-sdk/e2b/template_sync/main.py b/packages/python-sdk/e2b/template_sync/main.py new file mode 100644 index 0000000..e86d3e7 --- /dev/null +++ b/packages/python-sdk/e2b/template_sync/main.py @@ -0,0 +1,529 @@ +from datetime import datetime +from typing import Callable, List, Optional, Union + +from typing_extensions import Unpack + +from e2b.api.client.client import AuthenticatedClient +from e2b.connection_config import ApiParams, ConnectionConfig + +from e2b.api.client_sync import get_api_client +from e2b.template.consts import GZIP, RESOLVE_SYMLINKS +from e2b.template.logger import LogEntry, LogEntryEnd, LogEntryStart +from e2b.template.main import TemplateBase, TemplateClass +from e2b.template.types import BuildInfo, InstructionType, TemplateTag, TemplateTagInfo +from e2b.template_sync.build_api import ( + assign_tags, + check_alias_exists, + get_template_tags, + remove_tags, + get_build_status, + get_file_upload_link, + request_build, + trigger_build, + upload_file, + wait_for_build_finish, +) +from e2b.template.utils import normalize_build_arguments, read_dockerignore + + +class Template(TemplateBase): + """ + Synchronous template builder for E2B sandboxes. + """ + + @staticmethod + def _build( + api_client: AuthenticatedClient, + template: TemplateClass, + name: str, + tags: Optional[List[str]] = None, + cpu_count: int = 2, + memory_mb: int = 1024, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + request_timeout: Optional[float] = None, + ) -> BuildInfo: + """ + Internal implementation of the template build process + + :param api_client: Authenticated API client + :param template: The template to build + :param name: Name for the template + :param tags: Optional tags for the template + :param cpu_count: Number of CPUs allocated to the sandbox + :param memory_mb: Amount of memory in MB allocated to the sandbox + :param skip_cache: If True, forces a complete rebuild ignoring cache + :param on_build_logs: Callback function to receive build logs during the build process + """ + if skip_cache: + template._template._force = True + + # Create template + if on_build_logs: + tags_msg = f" with tags: {', '.join(tags)}" if tags else "" + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Requesting build for template: {name}{tags_msg}", + ) + ) + + response = request_build( + api_client, + name=name, + cpu_count=cpu_count, + memory_mb=memory_mb, + tags=tags, + ) + + template_id = response.template_id + build_id = response.build_id + response_tags = response.tags + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Template created with ID: {template_id}, Build ID: {build_id}", + ) + ) + + instructions_with_hashes = template._template._instructions_with_hashes() + + # Upload files + for index, file_upload in enumerate(instructions_with_hashes): + if file_upload["type"] != InstructionType.COPY: + continue + + args = file_upload.get("args", []) + src = args[0] if len(args) > 0 else None + force_upload = file_upload.get("forceUpload") + files_hash = file_upload.get("filesHash", None) + resolve_symlinks = file_upload.get("resolveSymlinks") + if resolve_symlinks is None: + resolve_symlinks = RESOLVE_SYMLINKS + gzip = file_upload.get("gzip") + if gzip is None: + gzip = GZIP + + if src is None or files_hash is None: + raise ValueError("Source path and files hash are required") + + stack_trace = None + if index + 1 < len(template._template._stack_traces): + stack_trace = template._template._stack_traces[index + 1] + + file_info = get_file_upload_link( + api_client, template_id, files_hash, stack_trace + ) + + if (force_upload and file_info.url) or ( + file_info.present is False and file_info.url + ): + upload_file( + api_client, + src, + template._template._file_context_path, + file_info.url, + [ + *template._template._file_ignore_patterns, + *read_dockerignore(template._template._file_context_path), + ], + resolve_symlinks, + gzip, + stack_trace, + request_timeout=request_timeout, + ) + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Uploaded '{src}'", + ) + ) + else: + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Skipping upload of '{src}', already cached", + ) + ) + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="All file uploads completed", + ) + ) + + # Start build + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="Starting building...", + ) + ) + + trigger_build( + api_client, + template_id, + build_id, + template._template._serialize(instructions_with_hashes), + ) + + return BuildInfo( + template_id=template_id, + build_id=build_id, + alias=name, + name=name, + tags=response_tags, + ) + + @staticmethod + def build( + template: TemplateClass, + name: Optional[str] = None, + *, + alias: Optional[str] = None, + tags: Optional[List[str]] = None, + cpu_count: int = 2, + memory_mb: int = 1024, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + **opts: Unpack[ApiParams], + ) -> BuildInfo: + """ + Build and deploy a template to E2B infrastructure. + + :param template: The template to build + :param name: Template name in 'name' or 'name:tag' format + :param alias: (Deprecated) Alias name for the template. Use name instead. + :param tags: Optional additional tags to assign to the template + :param cpu_count: Number of CPUs allocated to the sandbox + :param memory_mb: Amount of memory in MB allocated to the sandbox + :param skip_cache: If True, forces a complete rebuild ignoring cache + :param on_build_logs: Callback function to receive build logs during the build process + + Example + ```python + from e2b import Template + + template = ( + Template() + .from_python_image('3') + .copy('requirements.txt', '/home/user/') + .run_cmd('pip install -r /home/user/requirements.txt') + ) + + # Build with single tag + Template.build(template, 'my-python-env:v1.0') + + # Build with multiple tags + Template.build(template, 'my-python-env', tags=['v1.1.0', 'stable']) + ``` + """ + name = normalize_build_arguments(name, alias) + + try: + if on_build_logs: + on_build_logs( + LogEntryStart( + timestamp=datetime.now(), + message="Build started", + ) + ) + + config = ConnectionConfig(**opts) + api_client = get_api_client( + config, + ) + + data = Template._build( + api_client, + template, + name, + tags=tags, + cpu_count=cpu_count, + memory_mb=memory_mb, + skip_cache=skip_cache, + on_build_logs=on_build_logs, + # Only honor an explicitly set request_timeout for uploads; + # otherwise upload_file applies its 1-hour default. + request_timeout=opts.get("request_timeout"), + ) + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="Waiting for logs...", + ) + ) + + wait_for_build_finish( + api_client, + data.template_id, + data.build_id, + on_build_logs, + logs_refresh_frequency=TemplateBase._logs_refresh_frequency, + stack_traces=template._template._stack_traces, + ) + + return data + finally: + if on_build_logs: + on_build_logs( + LogEntryEnd( + timestamp=datetime.now(), + message="Build finished", + ) + ) + + @staticmethod + def build_in_background( + template: TemplateClass, + name: Optional[str] = None, + *, + alias: Optional[str] = None, + tags: Optional[List[str]] = None, + cpu_count: int = 2, + memory_mb: int = 1024, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + **opts: Unpack[ApiParams], + ) -> BuildInfo: + """ + Build and deploy a template to E2B infrastructure without waiting for completion. + + :param template: The template to build + :param name: Template name in 'name' or 'name:tag' format + :param alias: (Deprecated) Alias name for the template. Use name instead. + :param tags: Optional additional tags to assign to the template + :param cpu_count: Number of CPUs allocated to the sandbox + :param memory_mb: Amount of memory in MB allocated to the sandbox + :param skip_cache: If True, forces a complete rebuild ignoring cache + :return: BuildInfo containing the template ID and build ID + + Example + ```python + from e2b import Template + + template = ( + Template() + .from_python_image('3') + .run_cmd('echo "test"') + .set_start_cmd('echo "Hello"', 'sleep 1') + ) + + # Build with single tag + build_info = Template.build_in_background(template, 'my-python-env:v1.0') + + # Build with multiple tags + build_info = Template.build_in_background(template, 'my-python-env', tags=['v1.1.0', 'stable']) + ``` + """ + name = normalize_build_arguments(name, alias) + + config = ConnectionConfig(**opts) + api_client = get_api_client( + config, + ) + + return Template._build( + api_client, + template, + name, + tags=tags, + cpu_count=cpu_count, + memory_mb=memory_mb, + skip_cache=skip_cache, + on_build_logs=on_build_logs, + # Only honor an explicitly set request_timeout for uploads; + # otherwise upload_file applies its 1-hour default. + request_timeout=opts.get("request_timeout"), + ) + + @staticmethod + def get_build_status( + build_info: BuildInfo, + logs_offset: int = 0, + **opts: Unpack[ApiParams], + ): + """ + Get the status of a build. + + :param build_info: Build identifiers returned from build_in_background + :param logs_offset: Offset for fetching logs + :return: TemplateBuild containing the build status and logs + + Example + ```python + from e2b import Template + + build_info = Template.build_in_background(template, alias='my-template') + status = Template.get_build_status(build_info, logs_offset=0) + ``` + """ + config = ConnectionConfig(**opts) + api_client = get_api_client( + config, + ) + + return get_build_status( + api_client, + build_info.template_id, + build_info.build_id, + logs_offset, + ) + + @staticmethod + def exists( + name: str, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Check if a template with the given name exists. + + :param name: Template name to check + :return: True if the name exists, False otherwise + + Example + ```python + from e2b import Template + + exists = Template.exists('my-python-env') + if exists: + print('Template exists!') + ``` + """ + + return Template.alias_exists(name, **opts) + + @staticmethod + def alias_exists( + alias: str, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Check if a template with the given alias exists. + + Deprecated Use `exists` instead. + + :param alias: Template alias to check + :return: True if the alias exists, False otherwise + + Example + ```python + from e2b import Template + + exists = Template.alias_exists('my-python-env') + if exists: + print('Template exists!') + ``` + """ + config = ConnectionConfig(**opts) + api_client = get_api_client( + config, + ) + + return check_alias_exists(api_client, alias) + + @staticmethod + def assign_tags( + target_name: str, + tags: Union[str, List[str]], + **opts: Unpack[ApiParams], + ) -> TemplateTagInfo: + """ + Assign tag(s) to an existing template build. + + :param target_name: Template name in 'name:tag' format (the source build to tag from) + :param tags: Tag or tags to assign + :return: TemplateTagInfo with build_id and assigned tags + + Example + ```python + from e2b import Template + + # Assign a single tag + result = Template.assign_tags('my-template:v1.0', 'production') + + # Assign multiple tags + result = Template.assign_tags('my-template:v1.0', ['production', 'stable']) + ``` + """ + config = ConnectionConfig(**opts) + api_client = get_api_client( + config, + ) + + normalized_tags = [tags] if isinstance(tags, str) else tags + return assign_tags(api_client, target_name, normalized_tags) + + @staticmethod + def remove_tags( + name: str, + tags: Union[str, List[str]], + **opts: Unpack[ApiParams], + ) -> None: + """ + Remove tag(s) from a template. + + :param name: Template name + :param tags: Tag or tags to remove + + Example + ```python + from e2b import Template + + # Remove a single tag + Template.remove_tags('my-template', 'production') + + # Remove multiple tags + Template.remove_tags('my-template', ['production', 'stable']) + ``` + """ + config = ConnectionConfig(**opts) + api_client = get_api_client( + config, + ) + + normalized_tags = [tags] if isinstance(tags, str) else tags + remove_tags(api_client, name, normalized_tags) + + @staticmethod + def get_tags( + template_id: str, + **opts: Unpack[ApiParams], + ) -> List[TemplateTag]: + """ + Get all tags for a template. + + :param template_id: Template ID or name + :return: List of TemplateTag with tag name, build_id, and created_at + + Example + ```python + from e2b import Template + + tags = Template.get_tags('my-template') + for tag in tags: + print(f"Tag: {tag.tag}, Build: {tag.build_id}, Created: {tag.created_at}") + ``` + """ + config = ConnectionConfig(**opts) + api_client = get_api_client( + config, + ) + + return get_template_tags(api_client, template_id) diff --git a/packages/python-sdk/e2b/volume/client/__init__.py b/packages/python-sdk/e2b/volume/client/__init__.py new file mode 100644 index 0000000..10d90b0 --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/__init__.py @@ -0,0 +1,8 @@ +"""A client library for accessing E2B API""" + +from .client import AuthenticatedClient, Client + +__all__ = ( + "AuthenticatedClient", + "Client", +) diff --git a/packages/python-sdk/e2b/volume/client/api/__init__.py b/packages/python-sdk/e2b/volume/client/api/__init__.py new file mode 100644 index 0000000..81f9fa2 --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/api/__init__.py @@ -0,0 +1 @@ +"""Contains methods for accessing the API""" diff --git a/packages/python-sdk/e2b/volume/client/api/volumes/__init__.py b/packages/python-sdk/e2b/volume/client/api/volumes/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/api/volumes/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/packages/python-sdk/e2b/volume/client/api/volumes/delete_volumecontent_volume_id_path.py b/packages/python-sdk/e2b/volume/client/api/volumes/delete_volumecontent_volume_id_path.py new file mode 100644 index 0000000..7acae25 --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/api/volumes/delete_volumecontent_volume_id_path.py @@ -0,0 +1,174 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...types import UNSET, Response + + +def _get_kwargs( + volume_id: str, + *, + path: str, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["path"] = path + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "delete", + "url": f"/volumecontent/{volume_id}/path", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, +) -> Response[Union[Any, Error]]: + """Delete a path + + Args: + volume_id (str): + path (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + path=path, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, +) -> Optional[Union[Any, Error]]: + """Delete a path + + Args: + volume_id (str): + path (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + volume_id=volume_id, + client=client, + path=path, + ).parsed + + +async def asyncio_detailed( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, +) -> Response[Union[Any, Error]]: + """Delete a path + + Args: + volume_id (str): + path (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + path=path, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, +) -> Optional[Union[Any, Error]]: + """Delete a path + + Args: + volume_id (str): + path (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + volume_id=volume_id, + client=client, + path=path, + ) + ).parsed diff --git a/packages/python-sdk/e2b/volume/client/api/volumes/get_volumecontent_volume_id_dir.py b/packages/python-sdk/e2b/volume/client/api/volumes/get_volumecontent_volume_id_dir.py new file mode 100644 index 0000000..d3243be --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/api/volumes/get_volumecontent_volume_id_dir.py @@ -0,0 +1,204 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.volume_entry_stat import VolumeEntryStat +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + volume_id: str, + *, + path: str, + depth: Union[Unset, int] = 1, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["path"] = path + + params["depth"] = depth + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/volumecontent/{volume_id}/dir", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error, list["VolumeEntryStat"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for componentsschemas_volume_directory_listing_item_data in _response_200: + componentsschemas_volume_directory_listing_item = VolumeEntryStat.from_dict( + componentsschemas_volume_directory_listing_item_data + ) + + response_200.append(componentsschemas_volume_directory_listing_item) + + return response_200 + if response.status_code == 400: + response_400 = cast(Any, None) + return response_400 + if response.status_code == 404: + response_404 = cast(Any, None) + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error, list["VolumeEntryStat"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, + depth: Union[Unset, int] = 1, +) -> Response[Union[Any, Error, list["VolumeEntryStat"]]]: + """List directory contents + + Args: + volume_id (str): + path (str): + depth (Union[Unset, int]): Default: 1. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error, list['VolumeEntryStat']]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + path=path, + depth=depth, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, + depth: Union[Unset, int] = 1, +) -> Optional[Union[Any, Error, list["VolumeEntryStat"]]]: + """List directory contents + + Args: + volume_id (str): + path (str): + depth (Union[Unset, int]): Default: 1. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error, list['VolumeEntryStat']] + """ + + return sync_detailed( + volume_id=volume_id, + client=client, + path=path, + depth=depth, + ).parsed + + +async def asyncio_detailed( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, + depth: Union[Unset, int] = 1, +) -> Response[Union[Any, Error, list["VolumeEntryStat"]]]: + """List directory contents + + Args: + volume_id (str): + path (str): + depth (Union[Unset, int]): Default: 1. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error, list['VolumeEntryStat']]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + path=path, + depth=depth, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, + depth: Union[Unset, int] = 1, +) -> Optional[Union[Any, Error, list["VolumeEntryStat"]]]: + """List directory contents + + Args: + volume_id (str): + path (str): + depth (Union[Unset, int]): Default: 1. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error, list['VolumeEntryStat']] + """ + + return ( + await asyncio_detailed( + volume_id=volume_id, + client=client, + path=path, + depth=depth, + ) + ).parsed diff --git a/packages/python-sdk/e2b/volume/client/api/volumes/get_volumecontent_volume_id_file.py b/packages/python-sdk/e2b/volume/client/api/volumes/get_volumecontent_volume_id_file.py new file mode 100644 index 0000000..c644c13 --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/api/volumes/get_volumecontent_volume_id_file.py @@ -0,0 +1,179 @@ +from http import HTTPStatus +from io import BytesIO +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...types import UNSET, File, Response + + +def _get_kwargs( + volume_id: str, + *, + path: str, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["path"] = path + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/volumecontent/{volume_id}/file", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error, File]]: + if response.status_code == 200: + response_200 = File(payload=BytesIO(response.content)) + + return response_200 + if response.status_code == 404: + response_404 = cast(Any, None) + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error, File]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, +) -> Response[Union[Any, Error, File]]: + """Download file + + Args: + volume_id (str): + path (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error, File]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + path=path, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, +) -> Optional[Union[Any, Error, File]]: + """Download file + + Args: + volume_id (str): + path (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error, File] + """ + + return sync_detailed( + volume_id=volume_id, + client=client, + path=path, + ).parsed + + +async def asyncio_detailed( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, +) -> Response[Union[Any, Error, File]]: + """Download file + + Args: + volume_id (str): + path (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error, File]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + path=path, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, +) -> Optional[Union[Any, Error, File]]: + """Download file + + Args: + volume_id (str): + path (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error, File] + """ + + return ( + await asyncio_detailed( + volume_id=volume_id, + client=client, + path=path, + ) + ).parsed diff --git a/packages/python-sdk/e2b/volume/client/api/volumes/get_volumecontent_volume_id_path.py b/packages/python-sdk/e2b/volume/client/api/volumes/get_volumecontent_volume_id_path.py new file mode 100644 index 0000000..01b894f --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/api/volumes/get_volumecontent_volume_id_path.py @@ -0,0 +1,176 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.volume_entry_stat import VolumeEntryStat +from ...types import UNSET, Response + + +def _get_kwargs( + volume_id: str, + *, + path: str, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["path"] = path + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/volumecontent/{volume_id}/path", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, VolumeEntryStat]]: + if response.status_code == 200: + response_200 = VolumeEntryStat.from_dict(response.json()) + + return response_200 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, VolumeEntryStat]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, +) -> Response[Union[Error, VolumeEntryStat]]: + """Get path information + + Args: + volume_id (str): + path (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, VolumeEntryStat]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + path=path, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, +) -> Optional[Union[Error, VolumeEntryStat]]: + """Get path information + + Args: + volume_id (str): + path (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, VolumeEntryStat] + """ + + return sync_detailed( + volume_id=volume_id, + client=client, + path=path, + ).parsed + + +async def asyncio_detailed( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, +) -> Response[Union[Error, VolumeEntryStat]]: + """Get path information + + Args: + volume_id (str): + path (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, VolumeEntryStat]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + path=path, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, +) -> Optional[Union[Error, VolumeEntryStat]]: + """Get path information + + Args: + volume_id (str): + path (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, VolumeEntryStat] + """ + + return ( + await asyncio_detailed( + volume_id=volume_id, + client=client, + path=path, + ) + ).parsed diff --git a/packages/python-sdk/e2b/volume/client/api/volumes/patch_volumecontent_volume_id_path.py b/packages/python-sdk/e2b/volume/client/api/volumes/patch_volumecontent_volume_id_path.py new file mode 100644 index 0000000..42e7a98 --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/api/volumes/patch_volumecontent_volume_id_path.py @@ -0,0 +1,203 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.patch_volumecontent_volume_id_path_body import ( + PatchVolumecontentVolumeIDPathBody, +) +from ...models.volume_entry_stat import VolumeEntryStat +from ...types import UNSET, Response + + +def _get_kwargs( + volume_id: str, + *, + body: PatchVolumecontentVolumeIDPathBody, + path: str, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + params: dict[str, Any] = {} + + params["path"] = path + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": f"/volumecontent/{volume_id}/path", + "params": params, + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, VolumeEntryStat]]: + if response.status_code == 200: + response_200 = VolumeEntryStat.from_dict(response.json()) + + return response_200 + if response.status_code == 400: + response_400 = cast(Any, None) + return response_400 + if response.status_code == 404: + response_404 = cast(Any, None) + return response_404 + if response.status_code == 500: + response_500 = cast(Any, None) + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, VolumeEntryStat]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + body: PatchVolumecontentVolumeIDPathBody, + path: str, +) -> Response[Union[Any, VolumeEntryStat]]: + """Update path metadata + + Args: + volume_id (str): + path (str): + body (PatchVolumecontentVolumeIDPathBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, VolumeEntryStat]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + body=body, + path=path, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + body: PatchVolumecontentVolumeIDPathBody, + path: str, +) -> Optional[Union[Any, VolumeEntryStat]]: + """Update path metadata + + Args: + volume_id (str): + path (str): + body (PatchVolumecontentVolumeIDPathBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, VolumeEntryStat] + """ + + return sync_detailed( + volume_id=volume_id, + client=client, + body=body, + path=path, + ).parsed + + +async def asyncio_detailed( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + body: PatchVolumecontentVolumeIDPathBody, + path: str, +) -> Response[Union[Any, VolumeEntryStat]]: + """Update path metadata + + Args: + volume_id (str): + path (str): + body (PatchVolumecontentVolumeIDPathBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, VolumeEntryStat]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + body=body, + path=path, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + body: PatchVolumecontentVolumeIDPathBody, + path: str, +) -> Optional[Union[Any, VolumeEntryStat]]: + """Update path metadata + + Args: + volume_id (str): + path (str): + body (PatchVolumecontentVolumeIDPathBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, VolumeEntryStat] + """ + + return ( + await asyncio_detailed( + volume_id=volume_id, + client=client, + body=body, + path=path, + ) + ).parsed diff --git a/packages/python-sdk/e2b/volume/client/api/volumes/post_volumecontent_volume_id_dir.py b/packages/python-sdk/e2b/volume/client/api/volumes/post_volumecontent_volume_id_dir.py new file mode 100644 index 0000000..85308be --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/api/volumes/post_volumecontent_volume_id_dir.py @@ -0,0 +1,239 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.volume_entry_stat import VolumeEntryStat +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + volume_id: str, + *, + path: str, + uid: Union[Unset, int] = UNSET, + gid: Union[Unset, int] = UNSET, + mode: Union[Unset, int] = UNSET, + force: Union[Unset, bool] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["path"] = path + + params["uid"] = uid + + params["gid"] = gid + + params["mode"] = mode + + params["force"] = force + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/volumecontent/{volume_id}/dir", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error, VolumeEntryStat]]: + if response.status_code == 201: + response_201 = VolumeEntryStat.from_dict(response.json()) + + return response_201 + if response.status_code == 404: + response_404 = cast(Any, None) + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error, VolumeEntryStat]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, + uid: Union[Unset, int] = UNSET, + gid: Union[Unset, int] = UNSET, + mode: Union[Unset, int] = UNSET, + force: Union[Unset, bool] = UNSET, +) -> Response[Union[Any, Error, VolumeEntryStat]]: + """Create a directory + + Args: + volume_id (str): + path (str): + uid (Union[Unset, int]): + gid (Union[Unset, int]): + mode (Union[Unset, int]): + force (Union[Unset, bool]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error, VolumeEntryStat]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + path=path, + uid=uid, + gid=gid, + mode=mode, + force=force, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, + uid: Union[Unset, int] = UNSET, + gid: Union[Unset, int] = UNSET, + mode: Union[Unset, int] = UNSET, + force: Union[Unset, bool] = UNSET, +) -> Optional[Union[Any, Error, VolumeEntryStat]]: + """Create a directory + + Args: + volume_id (str): + path (str): + uid (Union[Unset, int]): + gid (Union[Unset, int]): + mode (Union[Unset, int]): + force (Union[Unset, bool]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error, VolumeEntryStat] + """ + + return sync_detailed( + volume_id=volume_id, + client=client, + path=path, + uid=uid, + gid=gid, + mode=mode, + force=force, + ).parsed + + +async def asyncio_detailed( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, + uid: Union[Unset, int] = UNSET, + gid: Union[Unset, int] = UNSET, + mode: Union[Unset, int] = UNSET, + force: Union[Unset, bool] = UNSET, +) -> Response[Union[Any, Error, VolumeEntryStat]]: + """Create a directory + + Args: + volume_id (str): + path (str): + uid (Union[Unset, int]): + gid (Union[Unset, int]): + mode (Union[Unset, int]): + force (Union[Unset, bool]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error, VolumeEntryStat]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + path=path, + uid=uid, + gid=gid, + mode=mode, + force=force, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + path: str, + uid: Union[Unset, int] = UNSET, + gid: Union[Unset, int] = UNSET, + mode: Union[Unset, int] = UNSET, + force: Union[Unset, bool] = UNSET, +) -> Optional[Union[Any, Error, VolumeEntryStat]]: + """Create a directory + + Args: + volume_id (str): + path (str): + uid (Union[Unset, int]): + gid (Union[Unset, int]): + mode (Union[Unset, int]): + force (Union[Unset, bool]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error, VolumeEntryStat] + """ + + return ( + await asyncio_detailed( + volume_id=volume_id, + client=client, + path=path, + uid=uid, + gid=gid, + mode=mode, + force=force, + ) + ).parsed diff --git a/packages/python-sdk/e2b/volume/client/api/volumes/put_volumecontent_volume_id_file.py b/packages/python-sdk/e2b/volume/client/api/volumes/put_volumecontent_volume_id_file.py new file mode 100644 index 0000000..61a38a6 --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/api/volumes/put_volumecontent_volume_id_file.py @@ -0,0 +1,259 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.volume_entry_stat import VolumeEntryStat +from ...types import UNSET, File, Response, Unset + + +def _get_kwargs( + volume_id: str, + *, + body: File, + path: str, + uid: Union[Unset, int] = UNSET, + gid: Union[Unset, int] = UNSET, + mode: Union[Unset, int] = UNSET, + force: Union[Unset, bool] = UNSET, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + params: dict[str, Any] = {} + + params["path"] = path + + params["uid"] = uid + + params["gid"] = gid + + params["mode"] = mode + + params["force"] = force + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "put", + "url": f"/volumecontent/{volume_id}/file", + "params": params, + } + + _kwargs["content"] = body.payload + + headers["Content-Type"] = "application/octet-stream" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error, VolumeEntryStat]]: + if response.status_code == 201: + response_201 = VolumeEntryStat.from_dict(response.json()) + + return response_201 + if response.status_code == 404: + response_404 = cast(Any, None) + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error, VolumeEntryStat]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + body: File, + path: str, + uid: Union[Unset, int] = UNSET, + gid: Union[Unset, int] = UNSET, + mode: Union[Unset, int] = UNSET, + force: Union[Unset, bool] = UNSET, +) -> Response[Union[Any, Error, VolumeEntryStat]]: + """Upload file + + Args: + volume_id (str): + path (str): + uid (Union[Unset, int]): + gid (Union[Unset, int]): + mode (Union[Unset, int]): + force (Union[Unset, bool]): + body (File): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error, VolumeEntryStat]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + body=body, + path=path, + uid=uid, + gid=gid, + mode=mode, + force=force, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + body: File, + path: str, + uid: Union[Unset, int] = UNSET, + gid: Union[Unset, int] = UNSET, + mode: Union[Unset, int] = UNSET, + force: Union[Unset, bool] = UNSET, +) -> Optional[Union[Any, Error, VolumeEntryStat]]: + """Upload file + + Args: + volume_id (str): + path (str): + uid (Union[Unset, int]): + gid (Union[Unset, int]): + mode (Union[Unset, int]): + force (Union[Unset, bool]): + body (File): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error, VolumeEntryStat] + """ + + return sync_detailed( + volume_id=volume_id, + client=client, + body=body, + path=path, + uid=uid, + gid=gid, + mode=mode, + force=force, + ).parsed + + +async def asyncio_detailed( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + body: File, + path: str, + uid: Union[Unset, int] = UNSET, + gid: Union[Unset, int] = UNSET, + mode: Union[Unset, int] = UNSET, + force: Union[Unset, bool] = UNSET, +) -> Response[Union[Any, Error, VolumeEntryStat]]: + """Upload file + + Args: + volume_id (str): + path (str): + uid (Union[Unset, int]): + gid (Union[Unset, int]): + mode (Union[Unset, int]): + force (Union[Unset, bool]): + body (File): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error, VolumeEntryStat]] + """ + + kwargs = _get_kwargs( + volume_id=volume_id, + body=body, + path=path, + uid=uid, + gid=gid, + mode=mode, + force=force, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + volume_id: str, + *, + client: Union[AuthenticatedClient, Client], + body: File, + path: str, + uid: Union[Unset, int] = UNSET, + gid: Union[Unset, int] = UNSET, + mode: Union[Unset, int] = UNSET, + force: Union[Unset, bool] = UNSET, +) -> Optional[Union[Any, Error, VolumeEntryStat]]: + """Upload file + + Args: + volume_id (str): + path (str): + uid (Union[Unset, int]): + gid (Union[Unset, int]): + mode (Union[Unset, int]): + force (Union[Unset, bool]): + body (File): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error, VolumeEntryStat] + """ + + return ( + await asyncio_detailed( + volume_id=volume_id, + client=client, + body=body, + path=path, + uid=uid, + gid=gid, + mode=mode, + force=force, + ) + ).parsed diff --git a/packages/python-sdk/e2b/volume/client/client.py b/packages/python-sdk/e2b/volume/client/client.py new file mode 100644 index 0000000..eeffd00 --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/client.py @@ -0,0 +1,286 @@ +import ssl +from typing import Any, Optional, Union + +import httpx +from attrs import define, evolve, field + + +@define +class Client: + """A class for keeping track of data related to the API + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: Optional[httpx.Timeout] = field( + default=None, kw_only=True, alias="timeout" + ) + _verify_ssl: Union[str, bool, ssl.SSLContext] = field( + default=True, kw_only=True, alias="verify_ssl" + ) + _follow_redirects: bool = field( + default=False, kw_only=True, alias="follow_redirects" + ) + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: Optional[httpx.Client] = field(default=None, init=False) + _async_client: Optional[httpx.AsyncClient] = field(default=None, init=False) + + def with_headers(self, headers: dict[str, str]) -> "Client": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "Client": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "Client": + """Get a new client matching this one with a new timeout (in seconds)""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "Client": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "Client": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client": + """Manually the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "Client": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) + + +@define +class AuthenticatedClient: + """A Client which has been authenticated for use on secured endpoints + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + token: The token to use for authentication + prefix: The prefix to use for the Authorization header + auth_header_name: The name of the Authorization header + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: Optional[httpx.Timeout] = field( + default=None, kw_only=True, alias="timeout" + ) + _verify_ssl: Union[str, bool, ssl.SSLContext] = field( + default=True, kw_only=True, alias="verify_ssl" + ) + _follow_redirects: bool = field( + default=False, kw_only=True, alias="follow_redirects" + ) + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: Optional[httpx.Client] = field(default=None, init=False) + _async_client: Optional[httpx.AsyncClient] = field(default=None, init=False) + + token: str + prefix: str = "Bearer" + auth_header_name: str = "Authorization" + + def with_headers(self, headers: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient": + """Get a new client matching this one with a new timeout (in seconds)""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._headers[self.auth_header_name] = ( + f"{self.prefix} {self.token}" if self.prefix else self.token + ) + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "AuthenticatedClient": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client( + self, async_client: httpx.AsyncClient + ) -> "AuthenticatedClient": + """Manually the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._headers[self.auth_header_name] = ( + f"{self.prefix} {self.token}" if self.prefix else self.token + ) + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "AuthenticatedClient": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) diff --git a/packages/python-sdk/e2b/volume/client/errors.py b/packages/python-sdk/e2b/volume/client/errors.py new file mode 100644 index 0000000..5f92e76 --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/errors.py @@ -0,0 +1,16 @@ +"""Contains shared errors types that can be raised from API functions""" + + +class UnexpectedStatus(Exception): + """Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True""" + + def __init__(self, status_code: int, content: bytes): + self.status_code = status_code + self.content = content + + super().__init__( + f"Unexpected status code: {status_code}\n\nResponse content:\n{content.decode(errors='ignore')}" + ) + + +__all__ = ["UnexpectedStatus"] diff --git a/packages/python-sdk/e2b/volume/client/models/__init__.py b/packages/python-sdk/e2b/volume/client/models/__init__.py new file mode 100644 index 0000000..befc988 --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/models/__init__.py @@ -0,0 +1,13 @@ +"""Contains all the data models used in inputs/outputs""" + +from .error import Error +from .patch_volumecontent_volume_id_path_body import PatchVolumecontentVolumeIDPathBody +from .volume_entry_stat import VolumeEntryStat +from .volume_entry_stat_type import VolumeEntryStatType + +__all__ = ( + "Error", + "PatchVolumecontentVolumeIDPathBody", + "VolumeEntryStat", + "VolumeEntryStatType", +) diff --git a/packages/python-sdk/e2b/volume/client/models/error.py b/packages/python-sdk/e2b/volume/client/models/error.py new file mode 100644 index 0000000..fde9c70 --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/models/error.py @@ -0,0 +1,67 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="Error") + + +@_attrs_define +class Error: + """ + Attributes: + code (str): Error code + message (str): Error message + """ + + code: str + message: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + code = self.code + + message = self.message + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "code": code, + "message": message, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + code = d.pop("code") + + message = d.pop("message") + + error = cls( + code=code, + message=message, + ) + + error.additional_properties = d + return error + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/volume/client/models/patch_volumecontent_volume_id_path_body.py b/packages/python-sdk/e2b/volume/client/models/patch_volumecontent_volume_id_path_body.py new file mode 100644 index 0000000..fd0014e --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/models/patch_volumecontent_volume_id_path_body.py @@ -0,0 +1,77 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="PatchVolumecontentVolumeIDPathBody") + + +@_attrs_define +class PatchVolumecontentVolumeIDPathBody: + """ + Attributes: + uid (Union[Unset, int]): + gid (Union[Unset, int]): + mode (Union[Unset, int]): + """ + + uid: Union[Unset, int] = UNSET + gid: Union[Unset, int] = UNSET + mode: Union[Unset, int] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + uid = self.uid + + gid = self.gid + + mode = self.mode + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if uid is not UNSET: + field_dict["uid"] = uid + if gid is not UNSET: + field_dict["gid"] = gid + if mode is not UNSET: + field_dict["mode"] = mode + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + uid = d.pop("uid", UNSET) + + gid = d.pop("gid", UNSET) + + mode = d.pop("mode", UNSET) + + patch_volumecontent_volume_id_path_body = cls( + uid=uid, + gid=gid, + mode=mode, + ) + + patch_volumecontent_volume_id_path_body.additional_properties = d + return patch_volumecontent_volume_id_path_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/volume/client/models/volume_entry_stat.py b/packages/python-sdk/e2b/volume/client/models/volume_entry_stat.py new file mode 100644 index 0000000..2642a83 --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/models/volume_entry_stat.py @@ -0,0 +1,145 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.volume_entry_stat_type import VolumeEntryStatType +from ..types import UNSET, Unset + +T = TypeVar("T", bound="VolumeEntryStat") + + +@_attrs_define +class VolumeEntryStat: + """ + Attributes: + name (str): + type_ (VolumeEntryStatType): + path (str): + size (int): + mode (int): + uid (int): + gid (int): + atime (datetime.datetime): + mtime (datetime.datetime): + ctime (datetime.datetime): + target (Union[Unset, str]): + """ + + name: str + type_: VolumeEntryStatType + path: str + size: int + mode: int + uid: int + gid: int + atime: datetime.datetime + mtime: datetime.datetime + ctime: datetime.datetime + target: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + type_ = self.type_.value + + path = self.path + + size = self.size + + mode = self.mode + + uid = self.uid + + gid = self.gid + + atime = self.atime.isoformat() + + mtime = self.mtime.isoformat() + + ctime = self.ctime.isoformat() + + target = self.target + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + "type": type_, + "path": path, + "size": size, + "mode": mode, + "uid": uid, + "gid": gid, + "atime": atime, + "mtime": mtime, + "ctime": ctime, + } + ) + if target is not UNSET: + field_dict["target"] = target + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + type_ = VolumeEntryStatType(d.pop("type")) + + path = d.pop("path") + + size = d.pop("size") + + mode = d.pop("mode") + + uid = d.pop("uid") + + gid = d.pop("gid") + + atime = isoparse(d.pop("atime")) + + mtime = isoparse(d.pop("mtime")) + + ctime = isoparse(d.pop("ctime")) + + target = d.pop("target", UNSET) + + volume_entry_stat = cls( + name=name, + type_=type_, + path=path, + size=size, + mode=mode, + uid=uid, + gid=gid, + atime=atime, + mtime=mtime, + ctime=ctime, + target=target, + ) + + volume_entry_stat.additional_properties = d + return volume_entry_stat + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/volume/client/models/volume_entry_stat_type.py b/packages/python-sdk/e2b/volume/client/models/volume_entry_stat_type.py new file mode 100644 index 0000000..e14e23c --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/models/volume_entry_stat_type.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class VolumeEntryStatType(str, Enum): + DIRECTORY = "directory" + FILE = "file" + SYMLINK = "symlink" + UNKNOWN = "unknown" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/volume/client/py.typed b/packages/python-sdk/e2b/volume/client/py.typed new file mode 100644 index 0000000..1aad327 --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 \ No newline at end of file diff --git a/packages/python-sdk/e2b/volume/client/types.py b/packages/python-sdk/e2b/volume/client/types.py new file mode 100644 index 0000000..1b96ca4 --- /dev/null +++ b/packages/python-sdk/e2b/volume/client/types.py @@ -0,0 +1,54 @@ +"""Contains some shared types for properties""" + +from collections.abc import Mapping, MutableMapping +from http import HTTPStatus +from typing import IO, BinaryIO, Generic, Literal, Optional, TypeVar, Union + +from attrs import define + + +class Unset: + def __bool__(self) -> Literal[False]: + return False + + +UNSET: Unset = Unset() + +# The types that `httpx.Client(files=)` can accept, copied from that library. +FileContent = Union[IO[bytes], bytes, str] +FileTypes = Union[ + # (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 = list[tuple[str, FileTypes]] + + +@define +class File: + """Contains information for file uploads""" + + payload: BinaryIO + file_name: Optional[str] = None + mime_type: Optional[str] = None + + def to_tuple(self) -> FileTypes: + """Return a tuple representation that httpx will accept for multipart/form-data""" + return self.file_name, self.payload, self.mime_type + + +T = TypeVar("T") + + +@define +class Response(Generic[T]): + """A response from an endpoint""" + + status_code: HTTPStatus + content: bytes + headers: MutableMapping[str, str] + parsed: Optional[T] + + +__all__ = ["UNSET", "File", "FileTypes", "RequestFiles", "Response", "Unset"] diff --git a/packages/python-sdk/e2b/volume/client_async/__init__.py b/packages/python-sdk/e2b/volume/client_async/__init__.py new file mode 100644 index 0000000..0df7490 --- /dev/null +++ b/packages/python-sdk/e2b/volume/client_async/__init__.py @@ -0,0 +1,88 @@ +import asyncio +import os +import weakref +from typing import Dict, Optional + +import httpx +from httpx import Limits +from httpx._types import ProxyTypes + +from e2b.api import make_async_logging_event_hooks +from e2b.api.metadata import default_headers +from e2b.exceptions import AuthenticationException +from e2b.volume.client.client import AuthenticatedClient as AsyncVolumeApiClient +from e2b.volume.connection_config import VolumeConnectionConfig + +limits = Limits( + max_keepalive_connections=int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS", "20")), + max_connections=int(os.getenv("E2B_MAX_CONNECTIONS", "2000")), + keepalive_expiry=int(os.getenv("E2B_KEEPALIVE_EXPIRY", "300")), +) + +TransportKey = Optional[ProxyTypes] + + +def get_api_client(config: VolumeConnectionConfig, **kwargs) -> AsyncVolumeApiClient: + if config.access_token is None: + raise AuthenticationException( + "Volume token is required for volume content operations. " + "Use `AsyncVolume.create`/`AsyncVolume.connect` to obtain it " + "or pass `token` in options.", + ) + + headers = { + **default_headers, + **(config.headers or {}), + } + + request_timeout = config.request_timeout + + return AsyncVolumeApiClient( + base_url=config.api_url, + token=config.access_token, + auth_header_name="Authorization", + prefix="Bearer", + headers=headers, + timeout=( + httpx.Timeout(request_timeout) if request_timeout is not None else None + ), + httpx_args={ + "proxy": config.proxy, + "transport": get_transport(config), + "event_hooks": make_async_logging_event_hooks(config.logger), + }, + **kwargs, + ) + + +class AsyncTransportWithLogger(httpx.AsyncHTTPTransport): + # Keyed weakly by the event loop object itself, not id(loop) — CPython + # reuses object ids, so a new loop could otherwise inherit a transport + # bound to a previous, closed loop. + _instances: weakref.WeakKeyDictionary[ + asyncio.AbstractEventLoop, + Dict[TransportKey, "AsyncTransportWithLogger"], + ] = weakref.WeakKeyDictionary() + + @property + def pool(self): + return self._pool + + +def get_transport(config: VolumeConnectionConfig) -> AsyncTransportWithLogger: + loop = asyncio.get_running_loop() + loop_instances = AsyncTransportWithLogger._instances.get(loop) + if loop_instances is None: + loop_instances = {} + AsyncTransportWithLogger._instances[loop] = loop_instances + + key: TransportKey = config.proxy + transport = loop_instances.get(key) + if transport is None: + transport = AsyncTransportWithLogger( + limits=limits, + proxy=config.proxy, + ) + loop_instances[key] = transport + + return transport diff --git a/packages/python-sdk/e2b/volume/client_sync/__init__.py b/packages/python-sdk/e2b/volume/client_sync/__init__.py new file mode 100644 index 0000000..4ceceda --- /dev/null +++ b/packages/python-sdk/e2b/volume/client_sync/__init__.py @@ -0,0 +1,80 @@ +import os +import threading +from typing import Dict, Optional + +import httpx +from httpx import Limits +from httpx._types import ProxyTypes + +from e2b.api import make_logging_event_hooks +from e2b.api.metadata import default_headers +from e2b.exceptions import AuthenticationException +from e2b.volume.client.client import AuthenticatedClient as VolumeApiClient +from e2b.volume.connection_config import VolumeConnectionConfig + +limits = Limits( + max_keepalive_connections=int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS", "20")), + max_connections=int(os.getenv("E2B_MAX_CONNECTIONS", "2000")), + keepalive_expiry=int(os.getenv("E2B_KEEPALIVE_EXPIRY", "300")), +) + +TransportKey = Optional[ProxyTypes] + + +def get_api_client(config: VolumeConnectionConfig, **kwargs) -> VolumeApiClient: + if config.access_token is None: + raise AuthenticationException( + "Volume token is required for volume content operations. " + "Use `Volume.create`/`Volume.connect` to obtain it " + "or pass `token` in options.", + ) + + headers = { + **default_headers, + **(config.headers or {}), + } + + request_timeout = config.request_timeout + + return VolumeApiClient( + base_url=config.api_url, + token=config.access_token, + auth_header_name="Authorization", + prefix="Bearer", + headers=headers, + timeout=( + httpx.Timeout(request_timeout) if request_timeout is not None else None + ), + httpx_args={ + "proxy": config.proxy, + "transport": get_transport(config), + "event_hooks": make_logging_event_hooks(config.logger), + }, + **kwargs, + ) + + +class TransportWithLogger(httpx.HTTPTransport): + _thread_local = threading.local() + + @property + def pool(self): + return self._pool + + +def get_transport(config: VolumeConnectionConfig) -> TransportWithLogger: + instances: Dict[TransportKey, TransportWithLogger] = getattr( + TransportWithLogger._thread_local, "instances", {} + ) + key: TransportKey = config.proxy + cached = instances.get(key) + if cached is not None: + return cached + + transport = TransportWithLogger( + limits=limits, + proxy=config.proxy, + ) + instances[key] = transport + TransportWithLogger._thread_local.instances = instances + return transport diff --git a/packages/python-sdk/e2b/volume/connection_config.py b/packages/python-sdk/e2b/volume/connection_config.py new file mode 100644 index 0000000..2662604 --- /dev/null +++ b/packages/python-sdk/e2b/volume/connection_config.py @@ -0,0 +1,145 @@ +import logging +import os + +from typing import Dict, Optional, TypedDict + +from httpx._types import ProxyTypes +from typing_extensions import Unpack + +from e2b.api.metadata import package_version + +REQUEST_TIMEOUT: float = 60.0 # 60 seconds + +# Timeout for volume file transfers, which stream large bodies and so must not +# inherit the short REQUEST_TIMEOUT. (Sandbox filesystem streaming instead +# bounds each chunk by the request timeout and leaves the total to the server.) +FILE_TIMEOUT: float = 3600.0 # 1 hour + + +class VolumeApiParams(TypedDict, total=False): + """ + Parameters for requests made to the volume content API. + """ + + domain: Optional[str] + """Domain to use for the volume API, defaults to `E2B_DOMAIN` or `e2b.app`.""" + + debug: Optional[bool] + """Whether to use debug mode, defaults to `E2B_DEBUG` environment variable.""" + + request_timeout: Optional[float] + """Timeout for the request in **seconds**, defaults to 60 seconds.""" + + headers: Optional[Dict[str, str]] + """Additional headers to send with the request.""" + + token: Optional[str] + """Volume auth token used for `Authorization: Bearer `.""" + + api_url: Optional[str] + """URL to use for the volume API, defaults to `E2B_VOLUME_API_URL` or `https://api.`.""" + + proxy: Optional[ProxyTypes] + """Proxy to use for the request.""" + + logger: Optional[logging.Logger] + """Logger used for request and response logging. Accepts a standard library `logging.Logger`.""" + + +class VolumeConnectionConfig: + """ + Configuration for the volume content API. + + Uses bearer token authentication and defaults to the volume content host. + """ + + @staticmethod + def _domain(): + return os.getenv("E2B_DOMAIN") or "e2b.app" + + @staticmethod + def _debug(): + return os.getenv("E2B_DEBUG", "false").lower() == "true" + + @staticmethod + def _volume_api_url(): + return os.getenv("E2B_VOLUME_API_URL") + + @staticmethod + def _get_request_timeout( + default_timeout: Optional[float], + request_timeout: Optional[float], + ): + if request_timeout == 0: + return None + elif request_timeout is not None: + return request_timeout + else: + return default_timeout + + def __init__( + self, + domain: Optional[str] = None, + debug: Optional[bool] = None, + token: Optional[str] = None, + api_url: Optional[str] = None, + request_timeout: Optional[float] = None, + headers: Optional[Dict[str, str]] = None, + proxy: Optional[ProxyTypes] = None, + logger: Optional[logging.Logger] = None, + ): + self.logger = logger + self.domain = domain or self._domain() + self.debug = debug if debug is not None else self._debug() + + self.api_url = ( + api_url + or self._volume_api_url() + or ("http://localhost:8080" if self.debug else f"https://api.{self.domain}") + ) + self.access_token = token + self.token = self.access_token + self.proxy = proxy + + self.headers = dict(headers) if headers else {} + self.headers["User-Agent"] = f"e2b-python-sdk/{package_version}" + + self.request_timeout = self._get_request_timeout( + REQUEST_TIMEOUT, request_timeout + ) + + def get_request_timeout(self, request_timeout: Optional[float] = None): + return self._get_request_timeout(self.request_timeout, request_timeout) + + def get_api_params( + self, + **opts: Unpack[VolumeApiParams], + ) -> dict: + """ + Get request parameters for the volume content API. + """ + domain = opts.get("domain") + debug = opts.get("debug") + headers = opts.get("headers") + request_timeout = opts.get("request_timeout") + token = opts.get("token") + api_url = opts.get("api_url") + proxy = opts.get("proxy") + logger = opts.get("logger") + + req_headers = self.headers.copy() + if headers is not None: + req_headers.update(headers) + + return dict( + VolumeApiParams( + domain=domain if domain is not None else self.domain, + debug=debug if debug is not None else self.debug, + token=token if token is not None else self.token, + api_url=api_url if api_url is not None else self.api_url, + request_timeout=self.get_request_timeout(request_timeout), + headers=req_headers, + proxy=proxy if proxy is not None else self.proxy, + logger=logger if logger is not None else self.logger, + ) + ) diff --git a/packages/python-sdk/e2b/volume/types.py b/packages/python-sdk/e2b/volume/types.py new file mode 100644 index 0000000..cfec12d --- /dev/null +++ b/packages/python-sdk/e2b/volume/types.py @@ -0,0 +1,62 @@ +import datetime +from dataclasses import dataclass +from typing import Optional + +from e2b.volume.client.models.volume_entry_stat_type import VolumeEntryStatType + +# Type alias for file type enum +VolumeFileType = VolumeEntryStatType + + +@dataclass +class VolumeInfo: + """Information about a volume.""" + + volume_id: str + """Volume ID.""" + name: str + """Volume name.""" + + +@dataclass +class VolumeAndToken(VolumeInfo): + """Information about a volume and its auth token.""" + + token: str + """Volume auth token.""" + + +@dataclass +class VolumeEntryStat: + """Volume entry stat information.""" + + name: str + """Name of the filesystem object.""" + type: VolumeFileType + """Type of the filesystem object.""" + path: str + """Path to the filesystem object.""" + size: int + """Size of the filesystem object.""" + mode: int + """Mode of the filesystem object.""" + uid: int + """User ID of the filesystem object.""" + gid: int + """Group ID of the filesystem object.""" + atime: datetime.datetime + """Access time.""" + mtime: datetime.datetime + """Modification time.""" + ctime: datetime.datetime + """Creation time.""" + target: Optional[str] = None + """Target path for symlinks.""" + + +__all__ = [ + "VolumeInfo", + "VolumeAndToken", + "VolumeEntryStat", + "VolumeFileType", +] diff --git a/packages/python-sdk/e2b/volume/utils.py b/packages/python-sdk/e2b/volume/utils.py new file mode 100644 index 0000000..cd9e3e5 --- /dev/null +++ b/packages/python-sdk/e2b/volume/utils.py @@ -0,0 +1,52 @@ +import datetime +from typing import Optional + +from e2b.volume.client.models import VolumeEntryStat as VolumeEntryStatApi +from e2b.volume.client.types import UNSET +from e2b.volume.types import VolumeEntryStat + + +def _ensure_utc(dt: datetime.datetime) -> datetime.datetime: + """Mark a timezone-naive datetime as UTC (API timestamps are UTC).""" + if dt.tzinfo is None: + return dt.replace(tzinfo=datetime.timezone.utc) + return dt + + +def convert_volume_entry_stat(api_stat: VolumeEntryStatApi) -> VolumeEntryStat: + """Convert API VolumeEntryStat to SDK VolumeEntryStat.""" + target: Optional[str] = None + if api_stat.target is not UNSET and api_stat.target is not None: + target = str(api_stat.target) + + return VolumeEntryStat( + name=api_stat.name, + type=api_stat.type_, + path=api_stat.path, + size=api_stat.size, + mode=api_stat.mode, + uid=api_stat.uid, + gid=api_stat.gid, + atime=_ensure_utc(api_stat.atime), + mtime=_ensure_utc(api_stat.mtime), + ctime=_ensure_utc(api_stat.ctime), + target=target, + ) + + +class DualMethod: + """Descriptor enabling the same name for a static (class-level) and instance method. + + When accessed on the class (e.g. ``Volume.get_info``), the static function + is returned. When accessed on an instance (e.g. ``vol.get_info``), the + instance method is returned as a bound method. + """ + + def __init__(self, static_fn, instance_fn): + self._static_fn = static_fn + self._instance_fn = instance_fn + + def __get__(self, obj, objtype=None): + if obj is None: + return self._static_fn + return self._instance_fn.__get__(obj, objtype) diff --git a/packages/python-sdk/e2b/volume/volume_async.py b/packages/python-sdk/e2b/volume/volume_async.py new file mode 100644 index 0000000..2e0d711 --- /dev/null +++ b/packages/python-sdk/e2b/volume/volume_async.py @@ -0,0 +1,639 @@ +from typing import AsyncIterator, IO, List, Literal, Optional, Union, cast, overload +from http import HTTPStatus + +import httpx + +from httpx._types import ProxyTypes +from typing_extensions import Unpack + +from e2b.api import handle_api_exception +from e2b.api.client.api.volumes import ( + post_volumes, + get_volumes, + get_volumes_volume_id, + delete_volumes_volume_id, +) +from e2b.api.client.models import ( + NewVolume as NewVolumeModel, + Error, +) +from e2b.api.client.types import Response +from e2b.api.client_async import get_api_client as get_core_api_client +from e2b.connection_config import ApiParams, ConnectionConfig +from e2b.exceptions import NotFoundException, VolumeException +from e2b.volume.client.api.volumes import ( + get_volumecontent_volume_id_path as get_path, + get_volumecontent_volume_id_dir as get_dir, + post_volumecontent_volume_id_dir as post_dir, + delete_volumecontent_volume_id_path as delete_path, + patch_volumecontent_volume_id_path as patch_path, + put_volumecontent_volume_id_file as put_file, +) +from e2b.volume.client.models import ( + Error as VolumeError, + PatchVolumecontentVolumeIDPathBody as PatchPathBody, + VolumeEntryStat as VolumeEntryStatApi, +) +from e2b.volume.client.types import File as FilePayload, UNSET +from e2b.volume.client_async import get_api_client as get_volume_api_client +from e2b.volume.connection_config import ( + VolumeApiParams, + VolumeConnectionConfig, + FILE_TIMEOUT, +) +from e2b.volume.types import ( + VolumeAndToken, + VolumeInfo, + VolumeEntryStat, +) +from e2b.io_utils import aiter_io_chunks +from e2b.volume.utils import DualMethod, convert_volume_entry_stat + + +class AsyncVolume: + """E2B Volume for persistent storage that can be mounted to sandboxes (async).""" + + def __init__( + self, + volume_id: str, + name: str, + token: Optional[str] = None, + domain: Optional[str] = None, + debug: Optional[bool] = None, + proxy: Optional[ProxyTypes] = None, + ): + self._volume_id = volume_id + self._name = name + self._token = token + self._domain = domain + self._debug = debug + self._proxy = proxy + + @property + def volume_id(self) -> str: + return self._volume_id + + @property + def name(self) -> str: + return self._name + + @property + def token(self) -> Optional[str]: + return self._token + + def _get_volume_config( + self, **opts: Unpack[VolumeApiParams] + ) -> VolumeConnectionConfig: + return VolumeConnectionConfig( + domain=opts.get("domain") or self._domain, + debug=opts.get("debug") if opts.get("debug") is not None else self._debug, + token=opts.get("token") or self._token, + api_url=opts.get("api_url"), + request_timeout=opts.get("request_timeout"), + headers=opts.get("headers"), + logger=opts.get("logger"), + proxy=opts.get("proxy") if opts.get("proxy") is not None else self._proxy, + ) + + @classmethod + async def create(cls, name: str, **opts: Unpack[ApiParams]) -> "AsyncVolume": + """ + Create a new volume. + + :param name: Name of the volume + + :return: An AsyncVolume instance for the new volume + """ + config = ConnectionConfig(**opts) + + api_client = get_core_api_client(config) + res = await post_volumes.asyncio_detailed( + body=NewVolumeModel(name=name), + client=api_client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, Error): + raise Exception(f"{res.parsed.message}: Request failed") + + vol = cls( + volume_id=res.parsed.volume_id, + name=res.parsed.name, + token=res.parsed.token, + domain=config.domain, + debug=config.debug, + proxy=config.proxy, + ) + return vol + + @classmethod + async def connect(cls, volume_id: str, **opts: Unpack[ApiParams]) -> "AsyncVolume": + """ + Connect to an existing volume by ID. + + :param volume_id: Volume ID + + :return: An AsyncVolume instance for the existing volume + """ + info = await cls.get_info(volume_id, **opts) + config = ConnectionConfig(**opts) + return cls( + volume_id=volume_id, + name=info.name, + token=info.token, + domain=config.domain, + debug=config.debug, + proxy=config.proxy, + ) + + @staticmethod + async def _class_get_info( + volume_id: str, **opts: Unpack[ApiParams] + ) -> VolumeAndToken: + """ + Get information about a volume. + + :param volume_id: Volume ID + + :return: Volume info + """ + config = ConnectionConfig(**opts) + + api_client = get_core_api_client(config) + res = await get_volumes_volume_id.asyncio_detailed( + volume_id, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Volume {volume_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, Error): + raise Exception(f"{res.parsed.message}: Request failed") + + return VolumeAndToken( + volume_id=res.parsed.volume_id, + name=res.parsed.name, + token=res.parsed.token, + ) + + @staticmethod + async def _class_list(**opts: Unpack[ApiParams]) -> List[VolumeInfo]: + """ + List all volumes. + + :return: List of volumes + """ + config = ConnectionConfig(**opts) + + api_client = get_core_api_client(config) + res = await get_volumes.asyncio_detailed( + client=api_client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + if res.parsed is None: + return [] + + if isinstance(res.parsed, Error): + raise Exception(f"{res.parsed.message}: Request failed") + + return [VolumeInfo(volume_id=v.volume_id, name=v.name) for v in res.parsed] + + @staticmethod + async def destroy(volume_id: str, **opts: Unpack[ApiParams]) -> bool: + """ + Destroy a volume. + + :param volume_id: Volume ID + """ + config = ConnectionConfig(**opts) + + api_client = get_core_api_client(config) + res = await delete_volumes_volume_id.asyncio_detailed( + volume_id, + client=api_client, + ) + + if res.status_code == 404: + return False + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + return True + + async def _instance_list( + self, path: str, depth: Optional[int] = None, **opts: Unpack[VolumeApiParams] + ) -> List[VolumeEntryStat]: + """ + List directory contents. + + :param path: Path to the directory + :param depth: Number of layers deep to recurse into the directory + :param opts: Connection options + + :return: List of items (files and directories) in the directory + """ + config = self._get_volume_config(**opts) + api_client = get_volume_api_client(config) + + res = await get_dir.asyncio_detailed( + self._volume_id, + path=path, + depth=depth if depth is not None else UNSET, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Path {path} not found") + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + if res.parsed is None: + return [] + + if isinstance(res.parsed, VolumeError): + raise Exception(f"{res.parsed.message}: Request failed") + + # VolumeDirectoryListing is a list according to the spec + if isinstance(res.parsed, list): + parsed_entries = cast(List[VolumeEntryStatApi], res.parsed) + return [convert_volume_entry_stat(entry) for entry in parsed_entries] + return [] + + async def make_dir( + self, + path: str, + uid: Optional[int] = None, + gid: Optional[int] = None, + mode: Optional[int] = None, + force: Optional[bool] = None, + **opts: Unpack[VolumeApiParams], + ) -> VolumeEntryStat: + """ + Create a directory. + + :param path: Path to the directory to create + :param uid: User ID of the created directory + :param gid: Group ID of the created directory + :param mode: Mode of the created directory + :param force: Create parent directories if they don't exist + :param opts: Connection options + + :return: Information about the created directory + """ + config = self._get_volume_config(**opts) + api_client = get_volume_api_client(config) + + res = await post_dir.asyncio_detailed( + self._volume_id, + path=path, + uid=uid if uid is not None else UNSET, + gid=gid if gid is not None else UNSET, + mode=mode if mode is not None else UNSET, + force=force if force is not None else UNSET, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Path {path} not found") + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, VolumeError): + raise Exception(f"{res.parsed.message}: Request failed") + + return convert_volume_entry_stat(res.parsed) + + async def exists(self, path: str, **opts: Unpack[VolumeApiParams]) -> bool: + """ + Check whether a file or directory exists. + + Uses get_info under the hood. Returns True if the path exists, + False if it does not (404). Other errors are re-raised. + + :param path: Path to the file or directory + :param opts: Connection options + + :return: True if the path exists, False otherwise + """ + try: + await self.get_info(path, **opts) + return True + except NotFoundException: + return False + + async def _instance_get_info( + self, path: str, **opts: Unpack[VolumeApiParams] + ) -> VolumeEntryStat: + """ + Get information about a file or directory. + + :param path: Path to the file or directory + :param opts: Connection options + + :return: Information about the entry + """ + config = self._get_volume_config(**opts) + api_client = get_volume_api_client(config) + + res = await get_path.asyncio_detailed( + self._volume_id, + path=path, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Path {path} not found") + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, VolumeError): + raise Exception(f"{res.parsed.message}: Request failed") + + return convert_volume_entry_stat(cast(VolumeEntryStatApi, res.parsed)) + + get_info = DualMethod(_class_get_info.__func__, _instance_get_info) + list = DualMethod(_class_list.__func__, _instance_list) + + async def update_metadata( + self, + path: str, + uid: Optional[int] = None, + gid: Optional[int] = None, + mode: Optional[int] = None, + **opts: Unpack[VolumeApiParams], + ) -> VolumeEntryStat: + """ + Update file or directory metadata. + + :param path: Path to the file or directory + :param uid: User ID of the file or directory + :param gid: Group ID of the file or directory + :param mode: Mode of the file or directory + :param opts: Connection options + + :return: Updated entry information + """ + config = self._get_volume_config(**opts) + api_client = get_volume_api_client(config) + + body = PatchPathBody( + uid=uid if uid is not None else UNSET, + gid=gid if gid is not None else UNSET, + mode=mode if mode is not None else UNSET, + ) + + res = await patch_path.asyncio_detailed( + self._volume_id, + path=path, + body=body, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Path {path} not found") + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + if res.parsed is None: + raise Exception("Body of the request is None") + + return convert_volume_entry_stat(cast(VolumeEntryStatApi, res.parsed)) + + @overload + async def read_file( + self, + path: str, + format: Literal["text"] = "text", + **opts: Unpack[VolumeApiParams], + ) -> str: ... + + @overload + async def read_file( + self, + path: str, + format: Literal["bytes"], + **opts: Unpack[VolumeApiParams], + ) -> bytes: ... + + @overload + async def read_file( + self, + path: str, + format: Literal["stream"], + stream_idle_timeout: Optional[float] = None, + **opts: Unpack[VolumeApiParams], + ) -> AsyncIterator[bytes]: ... + + async def read_file( + self, + path: str, + format: Literal["text", "bytes", "stream"] = "text", + stream_idle_timeout: Optional[float] = None, + **opts: Unpack[VolumeApiParams], + ) -> Union[str, bytes, AsyncIterator[bytes]]: + """ + Read file content. + + You can pass `text`, `bytes`, or `stream` to `format` to change the return type. + + :param path: Path to the file + :param format: Format of the file content—`text` by default + :param stream_idle_timeout: Idle timeout in **seconds** for a streamed + read (`format="stream"`)—abort if no chunk arrives within this + window while reading. Resets on every chunk, so it bounds a stalled + stream without limiting total transfer time. Defaults to the request + timeout; pass `0` to disable. + :param opts: Connection options + + :return: File content as string, bytes, or async iterator of bytes + """ + config = self._get_volume_config(**opts) + api_client = get_volume_api_client(config) + + params = {"path": path} + timeout = VolumeConnectionConfig._get_request_timeout( + FILE_TIMEOUT, opts.get("request_timeout") + ) + + if format == "stream": + # The request timeout bounds connection setup, not total transfer; + # consuming the body must not be killed by it. httpx's per-chunk + # `read` timeout becomes the idle-read timeout for the body + # (defaults to the request timeout), bounding a stalled stream + # without limiting total transfer time. Pass `0` to disable. + # Mirrors the sandbox files stream path. + idle_timeout = ( + timeout if stream_idle_timeout is None else stream_idle_timeout + ) + stream_timeout = httpx.Timeout(timeout, read=idle_timeout or None) + + async def stream_file() -> AsyncIterator[bytes]: + async with api_client.get_async_httpx_client().stream( + method="GET", + url=f"/volumecontent/{self._volume_id}/file", + params=params, + timeout=stream_timeout, + ) as response: + if response.status_code == 404: + raise NotFoundException(f"Path {path} not found") + + if response.status_code >= 300: + api_response = Response( + status_code=HTTPStatus(response.status_code), + content=await response.aread(), + headers=response.headers, + parsed=None, + ) + raise handle_api_exception(api_response, VolumeException) + + async for chunk in response.aiter_bytes(): + yield chunk + + return stream_file() + + response = await api_client.get_async_httpx_client().request( + method="GET", + url=f"/volumecontent/{self._volume_id}/file", + params=params, + timeout=timeout, + ) + + if response.status_code == 404: + raise NotFoundException(f"Path {path} not found") + + if response.status_code >= 300: + api_response = Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=None, + ) + raise handle_api_exception(api_response, VolumeException) + + if format == "bytes": + return response.content + else: + return response.text + + async def write_file( + self, + path: str, + data: Union[str, bytes, IO], + uid: Optional[int] = None, + gid: Optional[int] = None, + mode: Optional[int] = None, + force: Optional[bool] = None, + **opts: Unpack[VolumeApiParams], + ) -> VolumeEntryStat: + """ + Write content to a file. + + Writing to a file that doesn't exist creates the file. + Writing to a file that already exists overwrites the file. + + :param path: Path to the file + :param data: Data to write to the file. Data can be a string, bytes, or IO. File-like objects are streamed in chunks instead of being buffered in memory. + :param uid: User ID of the created file + :param gid: Group ID of the created file + :param mode: Mode of the created file + :param force: Force overwrite of an existing file + :param opts: Connection options + + :return: Information about the written file + """ + config = self._get_volume_config(**opts) + upload_timeout = VolumeConnectionConfig._get_request_timeout( + FILE_TIMEOUT, opts.get("request_timeout") + ) + api_client = get_volume_api_client(config) + if upload_timeout is not None: + api_client = api_client.with_timeout(httpx.Timeout(upload_timeout)) + + content: Union[bytes, AsyncIterator[bytes]] + if isinstance(data, str): + content = data.encode("utf-8") + elif isinstance(data, bytes): + content = data + elif hasattr(data, "read"): + # Stream file-like objects in chunks without buffering them in + # memory. Async httpx requires an async iterable request body. + content = aiter_io_chunks(data) + else: + raise ValueError(f"Unsupported data type: {type(data)}") + + res = await put_file.asyncio_detailed( + self._volume_id, + body=FilePayload(payload=content), # type: ignore[arg-type] # httpx accepts bytes and streamable content directly + path=path, + uid=uid if uid is not None else UNSET, + gid=gid if gid is not None else UNSET, + mode=mode if mode is not None else UNSET, + force=force if force is not None else UNSET, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Path {path} not found") + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, VolumeError): + raise Exception(f"{res.parsed.message}: Request failed") + + return convert_volume_entry_stat(cast(VolumeEntryStatApi, res.parsed)) + + async def remove( + self, + path: str, + **opts: Unpack[VolumeApiParams], + ) -> None: + """ + Remove a file or directory. + + :param path: Path to the file or directory to remove + :param opts: Connection options + """ + config = self._get_volume_config(**opts) + api_client = get_volume_api_client(config) + + res = await delete_path.asyncio_detailed( + self._volume_id, + path=path, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Path {path} not found") + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) diff --git a/packages/python-sdk/e2b/volume/volume_sync.py b/packages/python-sdk/e2b/volume/volume_sync.py new file mode 100644 index 0000000..9017526 --- /dev/null +++ b/packages/python-sdk/e2b/volume/volume_sync.py @@ -0,0 +1,639 @@ +import io +from typing import IO, Iterator, List, Literal, Optional, Union, cast, overload +from http import HTTPStatus + +import httpx + +from httpx._types import ProxyTypes +from typing_extensions import Unpack + +from e2b.api import handle_api_exception +from e2b.api.client.api.volumes import ( + post_volumes, + get_volumes, + get_volumes_volume_id, + delete_volumes_volume_id, +) +from e2b.api.client.models import ( + NewVolume as NewVolumeModel, + Error, +) +from e2b.api.client.types import Response +from e2b.api.client_sync import get_api_client as get_core_api_client +from e2b.connection_config import ApiParams, ConnectionConfig +from e2b.exceptions import NotFoundException, VolumeException +from e2b.volume.client.api.volumes import ( + get_volumecontent_volume_id_path as get_path, + get_volumecontent_volume_id_dir as get_dir, + post_volumecontent_volume_id_dir as post_dir, + delete_volumecontent_volume_id_path as delete_path, + patch_volumecontent_volume_id_path as patch_path, + put_volumecontent_volume_id_file as put_file, +) +from e2b.volume.client.models import ( + Error as VolumeError, + PatchVolumecontentVolumeIDPathBody as PatchPathBody, + VolumeEntryStat as VolumeEntryStatApi, +) +from e2b.volume.client.types import File as FilePayload, UNSET +from e2b.volume.client_sync import get_api_client as get_volume_api_client +from e2b.volume.connection_config import ( + VolumeApiParams, + VolumeConnectionConfig, + FILE_TIMEOUT, +) +from e2b.volume.types import ( + VolumeAndToken, + VolumeInfo, + VolumeEntryStat, +) +from e2b.io_utils import iter_io_chunks +from e2b.volume.utils import DualMethod, convert_volume_entry_stat + + +class Volume: + """E2B Volume for persistent storage that can be mounted to sandboxes.""" + + def __init__( + self, + volume_id: str, + name: str, + token: Optional[str] = None, + domain: Optional[str] = None, + debug: Optional[bool] = None, + proxy: Optional[ProxyTypes] = None, + ): + self._volume_id = volume_id + self._name = name + self._token = token + self._domain = domain + self._debug = debug + self._proxy = proxy + + @property + def volume_id(self) -> str: + return self._volume_id + + @property + def name(self) -> str: + return self._name + + @property + def token(self) -> Optional[str]: + return self._token + + def _get_volume_config( + self, **opts: Unpack[VolumeApiParams] + ) -> VolumeConnectionConfig: + return VolumeConnectionConfig( + domain=opts.get("domain") or self._domain, + debug=opts.get("debug") if opts.get("debug") is not None else self._debug, + token=opts.get("token") or self._token, + api_url=opts.get("api_url"), + request_timeout=opts.get("request_timeout"), + headers=opts.get("headers"), + logger=opts.get("logger"), + proxy=opts.get("proxy") if opts.get("proxy") is not None else self._proxy, + ) + + @classmethod + def create(cls, name: str, **opts: Unpack[ApiParams]) -> "Volume": + """ + Create a new volume. + + :param name: Name of the volume + + :return: A Volume instance for the new volume + """ + config = ConnectionConfig(**opts) + + api_client = get_core_api_client(config) + res = post_volumes.sync_detailed( + body=NewVolumeModel(name=name), + client=api_client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, Error): + raise Exception(f"{res.parsed.message}: Request failed") + + vol = cls( + volume_id=res.parsed.volume_id, + name=res.parsed.name, + token=res.parsed.token, + domain=config.domain, + debug=config.debug, + proxy=config.proxy, + ) + return vol + + @classmethod + def connect(cls, volume_id: str, **opts: Unpack[ApiParams]) -> "Volume": + """ + Connect to an existing volume by ID. + + :param volume_id: Volume ID + + :return: A Volume instance for the existing volume + """ + info = cls.get_info(volume_id, **opts) + config = ConnectionConfig(**opts) + return cls( + volume_id=volume_id, + name=info.name, + token=info.token, + domain=config.domain, + debug=config.debug, + proxy=config.proxy, + ) + + @staticmethod + def _class_get_info(volume_id: str, **opts: Unpack[ApiParams]) -> VolumeAndToken: + """ + Get information about a volume. + + :param volume_id: Volume ID + + :return: Volume info + """ + config = ConnectionConfig(**opts) + + api_client = get_core_api_client(config) + res = get_volumes_volume_id.sync_detailed( + volume_id, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Volume {volume_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, Error): + raise Exception(f"{res.parsed.message}: Request failed") + + return VolumeAndToken( + volume_id=res.parsed.volume_id, + name=res.parsed.name, + token=res.parsed.token, + ) + + @staticmethod + def _class_list(**opts: Unpack[ApiParams]) -> List[VolumeInfo]: + """ + List all volumes. + + :return: List of volumes + """ + config = ConnectionConfig(**opts) + + api_client = get_core_api_client(config) + res = get_volumes.sync_detailed( + client=api_client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + if res.parsed is None: + return [] + + if isinstance(res.parsed, Error): + raise Exception(f"{res.parsed.message}: Request failed") + + return [VolumeInfo(volume_id=v.volume_id, name=v.name) for v in res.parsed] + + @staticmethod + def destroy(volume_id: str, **opts: Unpack[ApiParams]) -> bool: + """ + Destroy a volume. + + :param volume_id: Volume ID + """ + config = ConnectionConfig(**opts) + + api_client = get_core_api_client(config) + res = delete_volumes_volume_id.sync_detailed( + volume_id, + client=api_client, + ) + + if res.status_code == 404: + return False + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + return True + + def _instance_list( + self, path: str, depth: Optional[int] = None, **opts: Unpack[VolumeApiParams] + ) -> List[VolumeEntryStat]: + """ + List directory contents. + + :param path: Path to the directory + :param depth: Number of layers deep to recurse into the directory + :param opts: Connection options + + :return: List of items (files and directories) in the directory + """ + config = self._get_volume_config(**opts) + api_client = get_volume_api_client(config) + + res = get_dir.sync_detailed( + self._volume_id, + path=path, + depth=depth if depth is not None else UNSET, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Path {path} not found") + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + if res.parsed is None: + return [] + + if isinstance(res.parsed, VolumeError): + raise Exception(f"{res.parsed.message}: Request failed") + + # VolumeDirectoryListing is a list according to the spec + if isinstance(res.parsed, list): + parsed_entries = cast(List[VolumeEntryStatApi], res.parsed) + return [convert_volume_entry_stat(entry) for entry in parsed_entries] + return [] + + def make_dir( + self, + path: str, + uid: Optional[int] = None, + gid: Optional[int] = None, + mode: Optional[int] = None, + force: Optional[bool] = None, + **opts: Unpack[VolumeApiParams], + ) -> VolumeEntryStat: + """ + Create a directory. + + :param path: Path to the directory to create + :param uid: User ID of the created directory + :param gid: Group ID of the created directory + :param mode: Mode of the created directory + :param force: Create parent directories if they don't exist + :param opts: Connection options + + :return: Information about the created directory + """ + config = self._get_volume_config(**opts) + api_client = get_volume_api_client(config) + + res = post_dir.sync_detailed( + self._volume_id, + path=path, + uid=uid if uid is not None else UNSET, + gid=gid if gid is not None else UNSET, + mode=mode if mode is not None else UNSET, + force=force if force is not None else UNSET, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Path {path} not found") + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, VolumeError): + raise Exception(f"{res.parsed.message}: Request failed") + + return convert_volume_entry_stat(res.parsed) + + def exists(self, path: str, **opts: Unpack[VolumeApiParams]) -> bool: + """ + Check whether a file or directory exists. + + Uses get_info under the hood. Returns True if the path exists, + False if it does not (404). Other errors are re-raised. + + :param path: Path to the file or directory + :param opts: Connection options + + :return: True if the path exists, False otherwise + """ + try: + self.get_info(path, **opts) + return True + except NotFoundException: + return False + + def _instance_get_info( + self, path: str, **opts: Unpack[VolumeApiParams] + ) -> VolumeEntryStat: + """ + Get information about a file or directory. + + :param path: Path to the file or directory + :param opts: Connection options + + :return: Information about the entry + """ + config = self._get_volume_config(**opts) + api_client = get_volume_api_client(config) + + res = get_path.sync_detailed( + self._volume_id, + path=path, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Path {path} not found") + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, VolumeError): + raise Exception(f"{res.parsed.message}: Request failed") + + return convert_volume_entry_stat(cast(VolumeEntryStatApi, res.parsed)) + + get_info = DualMethod(_class_get_info.__func__, _instance_get_info) + list = DualMethod(_class_list.__func__, _instance_list) + + def update_metadata( + self, + path: str, + uid: Optional[int] = None, + gid: Optional[int] = None, + mode: Optional[int] = None, + **opts: Unpack[VolumeApiParams], + ) -> VolumeEntryStat: + """ + Update file or directory metadata. + + :param path: Path to the file or directory + :param uid: User ID of the file or directory + :param gid: Group ID of the file or directory + :param mode: Mode of the file or directory + :param opts: Connection options + + :return: Updated entry information + """ + config = self._get_volume_config(**opts) + api_client = get_volume_api_client(config) + + body = PatchPathBody( + uid=uid if uid is not None else UNSET, + gid=gid if gid is not None else UNSET, + mode=mode if mode is not None else UNSET, + ) + + res = patch_path.sync_detailed( + self._volume_id, + path=path, + body=body, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Path {path} not found") + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + if res.parsed is None: + raise Exception("Body of the request is None") + + return convert_volume_entry_stat(cast(VolumeEntryStatApi, res.parsed)) + + @overload + def read_file( + self, + path: str, + format: Literal["text"] = "text", + **opts: Unpack[VolumeApiParams], + ) -> str: ... + + @overload + def read_file( + self, + path: str, + format: Literal["bytes"], + **opts: Unpack[VolumeApiParams], + ) -> bytes: ... + + @overload + def read_file( + self, + path: str, + format: Literal["stream"], + stream_idle_timeout: Optional[float] = None, + **opts: Unpack[VolumeApiParams], + ) -> Iterator[bytes]: ... + + def read_file( + self, + path: str, + format: Literal["text", "bytes", "stream"] = "text", + stream_idle_timeout: Optional[float] = None, + **opts: Unpack[VolumeApiParams], + ) -> Union[str, bytes, Iterator[bytes]]: + """ + Read file content. + + You can pass `text`, `bytes`, or `stream` to `format` to change the return type. + + :param path: Path to the file + :param format: Format of the file content—`text` by default + :param stream_idle_timeout: Idle timeout in **seconds** for a streamed + read (`format="stream"`)—abort if no chunk arrives within this + window while reading. Resets on every chunk, so it bounds a stalled + stream without limiting total transfer time. Defaults to the request + timeout; pass `0` to disable. + :param opts: Connection options + + :return: File content as string, bytes, or iterator of bytes + """ + config = self._get_volume_config(**opts) + api_client = get_volume_api_client(config) + + params = {"path": path} + timeout = VolumeConnectionConfig._get_request_timeout( + FILE_TIMEOUT, opts.get("request_timeout") + ) + + if format == "stream": + # The request timeout bounds connection setup, not total transfer; + # consuming the body must not be killed by it. httpx's per-chunk + # `read` timeout becomes the idle-read timeout for the body + # (defaults to the request timeout), bounding a stalled stream + # without limiting total transfer time. Pass `0` to disable. + # Mirrors the sandbox files stream path. + idle_timeout = ( + timeout if stream_idle_timeout is None else stream_idle_timeout + ) + stream_timeout = httpx.Timeout(timeout, read=idle_timeout or None) + + def stream_file() -> Iterator[bytes]: + with api_client.get_httpx_client().stream( + method="GET", + url=f"/volumecontent/{self._volume_id}/file", + params=params, + timeout=stream_timeout, + ) as response: + if response.status_code == 404: + raise NotFoundException(f"Path {path} not found") + + if response.status_code >= 300: + api_response = Response( + status_code=HTTPStatus(response.status_code), + content=response.read(), + headers=response.headers, + parsed=None, + ) + raise handle_api_exception(api_response, VolumeException) + + yield from response.iter_bytes() + + return stream_file() + + response = api_client.get_httpx_client().request( + method="GET", + url=f"/volumecontent/{self._volume_id}/file", + params=params, + timeout=timeout, + ) + + if response.status_code == 404: + raise NotFoundException(f"Path {path} not found") + + if response.status_code >= 300: + api_response = Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=None, + ) + raise handle_api_exception(api_response, VolumeException) + + if format == "bytes": + return response.content + else: + return response.text + + def write_file( + self, + path: str, + data: Union[str, bytes, IO], + uid: Optional[int] = None, + gid: Optional[int] = None, + mode: Optional[int] = None, + force: Optional[bool] = None, + **opts: Unpack[VolumeApiParams], + ) -> VolumeEntryStat: + """ + Write content to a file. + + Writing to a file that doesn't exist creates the file. + Writing to a file that already exists overwrites the file. + + :param path: Path to the file + :param data: Data to write to the file. Data can be a string, bytes, or IO. File-like objects are streamed in chunks instead of being buffered in memory. + :param uid: User ID of the created file + :param gid: Group ID of the created file + :param mode: Mode of the created file + :param force: Force overwrite of an existing file + :param opts: Connection options + + :return: Information about the written file + """ + config = self._get_volume_config(**opts) + upload_timeout = VolumeConnectionConfig._get_request_timeout( + FILE_TIMEOUT, opts.get("request_timeout") + ) + api_client = get_volume_api_client(config) + if upload_timeout is not None: + api_client = api_client.with_timeout(httpx.Timeout(upload_timeout)) + + content: Union[bytes, IO[bytes], Iterator[bytes]] + if isinstance(data, str): + content = data.encode("utf-8") + elif isinstance(data, bytes): + content = data + elif isinstance(data, io.TextIOBase): + # Text-mode IO yields str chunks—encode them while streaming. + content = iter_io_chunks(data) + elif hasattr(data, "read"): + # httpx streams file-like objects in chunks without buffering. + content = data + else: + raise ValueError(f"Unsupported data type: {type(data)}") + + res = put_file.sync_detailed( + self._volume_id, + body=FilePayload(payload=content), # type: ignore[arg-type] # httpx accepts bytes and streamable content directly + path=path, + uid=uid if uid is not None else UNSET, + gid=gid if gid is not None else UNSET, + mode=mode if mode is not None else UNSET, + force=force if force is not None else UNSET, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Path {path} not found") + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, VolumeError): + raise Exception(f"{res.parsed.message}: Request failed") + + return convert_volume_entry_stat(cast(VolumeEntryStatApi, res.parsed)) + + def remove( + self, + path: str, + **opts: Unpack[VolumeApiParams], + ) -> None: + """ + Remove a file or directory. + + :param path: Path to the file or directory to remove + :param opts: Connection options + """ + config = self._get_volume_config(**opts) + api_client = get_volume_api_client(config) + + res = delete_path.sync_detailed( + self._volume_id, + path=path, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Path {path} not found") + + if res.status_code >= 300: + raise handle_api_exception(res, VolumeException) diff --git a/packages/python-sdk/e2b_connect/__init__.py b/packages/python-sdk/e2b_connect/__init__.py new file mode 100644 index 0000000..6f31c8c --- /dev/null +++ b/packages/python-sdk/e2b_connect/__init__.py @@ -0,0 +1 @@ +from .client import Client, GzipCompressor, ConnectException, Code # noqa: F401 diff --git a/packages/python-sdk/e2b_connect/client.py b/packages/python-sdk/e2b_connect/client.py new file mode 100644 index 0000000..83d006b --- /dev/null +++ b/packages/python-sdk/e2b_connect/client.py @@ -0,0 +1,534 @@ +import gzip +import inspect +import json +import logging +import struct +import typing + +from httpcore import ( + ConnectionPool, + AsyncConnectionPool, + RemoteProtocolError, + Response, +) +from enum import Flag, Enum +from typing import Callable, Optional, Dict, Any, Generator, Tuple +from google.protobuf import json_format + + +class EnvelopeFlags(Flag): + compressed = 0b00000001 + end_stream = 0b00000010 + + +class Code(Enum): + canceled = "canceled" + unknown = "unknown" + invalid_argument = "invalid_argument" + deadline_exceeded = "deadline_exceeded" + not_found = "not_found" + already_exists = "already_exists" + permission_denied = "permission_denied" + resource_exhausted = "resource_exhausted" + failed_precondition = "failed_precondition" + aborted = "aborted" + out_of_range = "out_of_range" + unimplemented = "unimplemented" + internal = "internal" + unavailable = "unavailable" + data_loss = "data_loss" + unauthenticated = "unauthenticated" + + +def make_error_from_http_code(http_code: int): + error_code_map = { + 400: Code.invalid_argument, + 401: Code.unauthenticated, + 403: Code.permission_denied, + 404: Code.not_found, + 409: Code.already_exists, + 413: Code.resource_exhausted, + 429: Code.resource_exhausted, + 499: Code.canceled, + 500: Code.internal, + 501: Code.unimplemented, + 502: Code.unavailable, + 503: Code.unavailable, + 504: Code.deadline_exceeded, + 505: Code.unimplemented, + } + + return error_code_map.get(http_code, Code.unknown) + + +class ConnectException(Exception): + def __init__(self, status: Code, message: str): + self.status = status + self.message = message + + +envelope_header_length = 5 +envelope_header_pack = ">BI" + + +def encode_envelope(*, flags: EnvelopeFlags, data): + return encode_envelope_header(flags=flags.value, data=data) + data + + +def encode_envelope_header(*, flags, data): + return struct.pack(envelope_header_pack, flags, len(data)) + + +def decode_envelope_header(header): + flags, data_len = struct.unpack(envelope_header_pack, header) + return EnvelopeFlags(flags), data_len + + +def error_for_response(http_resp: Response): + try: + error = json.loads(http_resp.content) + return make_error(error) + except (json.decoder.JSONDecodeError, KeyError): + error = {"code": http_resp.status, "message": http_resp.content.decode("utf-8")} + return make_error(error) + + +def make_error(error): + status = None + try: + code_value = error.get("code") + # return error code from http status code + if isinstance(code_value, int): + status = make_error_from_http_code(code_value) + else: + status = Code(code_value) + except (KeyError, ValueError): + status = Code.unknown + + return ConnectException(status, error.get("message", "")) + + +def _sync_retry(func, exc, retries): + def retry(*args, **kwargs): + for _ in range(retries): + try: + return func(*args, **kwargs) + except exc: + continue + + return func(*args, **kwargs) + + return retry + + +def _async_retry(func, exc, retries): + async def retry(*args, **kwargs): + for _ in range(retries): + try: + return await func(*args, **kwargs) + except exc: + continue + + return await func(*args, **kwargs) + + return retry + + +def _retry(exc: typing.Type[Exception], retries: int): + def decorator(func): + if inspect.iscoroutinefunction(func): + return _async_retry(func, exc, retries) + + return _sync_retry(func, exc, retries) + + return decorator + + +class GzipCompressor: + name = "gzip" + decompress = gzip.decompress + compress = gzip.compress + + +class JSONCodec: + content_type = "json" + + @staticmethod + def encode(msg): + return json_format.MessageToJson(msg).encode("utf8") + + @staticmethod + def decode(data, *, msg_type): + msg = msg_type() + json_format.Parse(data.decode("utf8"), msg, ignore_unknown_fields=True) + return msg + + +class ProtobufCodec: + content_type = "proto" + + @staticmethod + def encode(msg): + return msg.SerializeToString() + + @staticmethod + def decode(data, *, msg_type): + msg = msg_type() + msg.ParseFromString(data) + return msg + + +class Client: + def __init__( + self, + *, + pool: Optional[ConnectionPool] = None, + async_pool: Optional[AsyncConnectionPool] = None, + url: str, + response_type, + compressor=None, + json: Optional[bool] = False, + headers: Optional[Dict[str, str]] = None, + logger: Optional[logging.Logger] = None, + ): + if headers is None: + headers = {} + + self.pool = pool + self.async_pool = async_pool + self.url = url + self._codec = JSONCodec if json else ProtobufCodec + self._response_type = response_type + self._compressor = compressor + self._headers = headers + self._connection_retries = 3 + self._logger = logger + + def _log_request(self) -> None: + if self._logger is not None: + self._logger.info(f"Request: POST {self.url}") + + def _log_response(self, status: int) -> None: + if self._logger is None: + return + if status >= 400: + self._logger.error(f"Response: {status} {self.url}") + else: + self._logger.info(f"Response: {status} {self.url}") + + def _log_stream_message(self) -> None: + if self._logger is not None: + self._logger.debug(f"Response stream: {self.url}") + + def _prepare_unary_request( + self, + req, + request_timeout=None, + headers: Optional[dict] = None, + **opts, + ) -> dict: + data = self._codec.encode(req) + + if self._compressor is not None: + data = self._compressor.compress(data) + + if headers is None: + headers = {} + + extensions = ( + None + if request_timeout is None + else { + "timeout": { + "connect": request_timeout, + "pool": request_timeout, + "read": request_timeout, + "write": request_timeout, + } + } + ) + + return { + "method": "POST", + "url": self.url, + "content": data, + "extensions": extensions, + "headers": { + **self._headers, + **headers, + **opts.get("headers", {}), + "connect-protocol-version": "1", + "content-encoding": ( + "identity" if self._compressor is None else self._compressor.name + ), + "content-type": f"application/{self._codec.content_type}", + }, + } + + def _process_unary_response( + self, + http_resp: Response, + ): + self._log_response(http_resp.status) + + if http_resp.status != 200: + raise error_for_response(http_resp) + + content = http_resp.content + + if self._compressor is not None: + content = self._compressor.decompress(content) + + return self._codec.decode( + content, + msg_type=self._response_type, + ) + + @_retry(RemoteProtocolError, 3) + async def acall_unary( + self, + req, + request_timeout=None, + headers: Optional[dict] = None, + **opts, + ): + if self.async_pool is None: + raise ValueError("async_pool is required") + + req_data = self._prepare_unary_request( + req, + request_timeout, + headers, + **opts, + ) + + self._log_request() + res = await self.async_pool.request(**req_data) + return self._process_unary_response(res) + + @_retry(RemoteProtocolError, 3) + def call_unary( + self, + req, + request_timeout=None, + headers: Optional[dict] = None, + **opts, + ): + if self.pool is None: + raise ValueError("pool is required") + + req_data = self._prepare_unary_request( + req, + request_timeout, + headers, + **opts, + ) + + self._log_request() + res = self.pool.request(**req_data) + return self._process_unary_response(res) + + def _create_stream_timeout(self, timeout: Optional[float]): + if timeout: + return {"connect-timeout-ms": str(int(timeout * 1000))} + return {} + + def _prepare_server_stream_request( + self, + req, + request_timeout=None, + timeout=None, + headers: Optional[dict] = None, + **opts, + ) -> dict: + headers = headers or {} + data = self._codec.encode(req) + flags = EnvelopeFlags(0) + + # `request_timeout` bounds connection setup and request sending, but NOT the + # stream read: a stream can stay open for the whole command `timeout` (minutes + # or, when disabled, indefinitely), so we deliberately leave `read` unset. + # The command `timeout` is enforced server-side via the `connect-timeout-ms` + # header (see `_create_stream_timeout`), which returns a clean `deadline_exceeded`. + # This mirrors the JS SDK, which has no per-chunk read timeout either — setting + # `read` to the command `timeout` would race that server response and surface a + # raw transport `ReadTimeout` instead. + timeout_ext = {} + if request_timeout is not None: + timeout_ext["connect"] = request_timeout + timeout_ext["pool"] = request_timeout + timeout_ext["write"] = request_timeout + extensions = {"timeout": timeout_ext} if timeout_ext else None + + if self._compressor is not None: + data = self._compressor.compress(data) + flags |= EnvelopeFlags.compressed + + stream_timeout = self._create_stream_timeout(timeout) + + return { + "method": "POST", + "url": self.url, + "content": encode_envelope( + flags=flags, + data=data, + ), + "extensions": extensions, + "headers": { + **self._headers, + **headers, + **opts.get("headers", {}), + **stream_timeout, + "connect-protocol-version": "1", + "connect-content-encoding": ( + "identity" if self._compressor is None else self._compressor.name + ), + "content-type": f"application/connect+{self._codec.content_type}", + }, + } + + # Note: no retry here — generator functions don't execute until iterated, so a + # call-level retry never fires, and retrying mid-stream would replay delivered events. + async def acall_server_stream( + self, + req, + request_timeout=None, + timeout=None, + headers: Optional[dict] = None, + **opts, + ): + if self.async_pool is None: + raise ValueError("async_pool is required") + + req_data = self._prepare_server_stream_request( + req, + request_timeout, + timeout, + headers, + **opts, + ) + + parser = ServerStreamParser( + decode=self._codec.decode, + response_type=self._response_type, + ) + + self._log_request() + async with self.async_pool.stream(**req_data) as http_resp: + if http_resp.status != 200: + self._log_response(http_resp.status) + await http_resp.aread() + raise error_for_response(http_resp) + + async for chunk in http_resp.aiter_stream(): + for parsed in parser.parse(chunk): + self._log_stream_message() + yield parsed + + def call_server_stream( + self, + req, + request_timeout=None, + timeout=None, + headers: Optional[dict] = None, + **opts, + ): + if self.pool is None: + raise ValueError("pool is required") + + req_data = self._prepare_server_stream_request( + req, + request_timeout, + timeout, + headers, + **opts, + ) + + parser = ServerStreamParser( + decode=self._codec.decode, + response_type=self._response_type, + ) + + self._log_request() + with self.pool.stream(**req_data) as http_resp: + if http_resp.status != 200: + self._log_response(http_resp.status) + http_resp.read() + raise error_for_response(http_resp) + + for chunk in http_resp.iter_stream(): + for parsed in parser.parse(chunk): + self._log_stream_message() + yield parsed + + def call_client_stream(self, req, **opts): + raise NotImplementedError("client stream not supported") + + def acall_client_stream(self, req, **opts): + raise NotImplementedError("client stream not supported") + + def call_bidi_stream(self, req, **opts): + raise NotImplementedError("bidi stream not supported") + + def acall_bidi_stream(self, req, **opts): + raise NotImplementedError("bidi stream not supported") + + +DataLen = int + + +class ServerStreamParser: + def __init__( + self, + decode: Callable, + response_type: Any, + ): + self.decode = decode + self.response_type = response_type + + self.buffer: bytes = b"" + self._header: Optional[tuple[EnvelopeFlags, DataLen]] = None + + def shift_buffer(self, size: int): + buffer = self.buffer[:size] + self.buffer = self.buffer[size:] + return buffer + + @property + def header(self) -> Tuple[EnvelopeFlags, DataLen]: + if self._header: + return self._header + + header_data = self.shift_buffer(envelope_header_length) + self._header = decode_envelope_header(header_data) + + return self._header + + @header.deleter + def header(self): + self._header = None + + def parse(self, chunk: bytes) -> Generator[Any, None, None]: + self.buffer += chunk + + # Once the header is consumed, the remaining payload can be shorter + # than the header length, so only require a full header when we still + # need to read one. + while self._header is not None or len(self.buffer) >= envelope_header_length: + flags, data_len = self.header + + if data_len > len(self.buffer): + break + + data = self.shift_buffer(data_len) + + if EnvelopeFlags.end_stream in flags: + data = json.loads(data) + + if "error" in data: + raise make_error(data["error"]) + + return + + yield self.decode(data, msg_type=self.response_type) + del self.header diff --git a/packages/python-sdk/e2b_connect/py.typed b/packages/python-sdk/e2b_connect/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/python-sdk/example.py b/packages/python-sdk/example.py new file mode 100644 index 0000000..6f8daa6 --- /dev/null +++ b/packages/python-sdk/example.py @@ -0,0 +1,18 @@ +import asyncio +import logging +from e2b import AsyncSandbox + +import dotenv + +dotenv.load_dotenv() + +logging.basicConfig(level=logging.ERROR) + + +async def main(): + sbx = await AsyncSandbox.create(timeout=10) + await sbx.set_timeout(20) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/python-sdk/package.json b/packages/python-sdk/package.json new file mode 100644 index 0000000..f95b453 --- /dev/null +++ b/packages/python-sdk/package.json @@ -0,0 +1,14 @@ +{ + "name": "@e2b/python-sdk", + "private": true, + "version": "2.31.0", + "scripts": { + "example": "uv run python example.py", + "test": "uv run pytest -n 4 --verbose -x", + "postVersion": "uv version $(pnpm pkg get version --workspaces=false | tr -d \\\")", + "postPublish": "uv build && uv publish --token ${PYPI_TOKEN} --check-url https://pypi.org/simple/", + "typecheck": "uv run make typecheck", + "lint": "uv run make lint", + "format": "uv run make format" + } +} diff --git a/packages/python-sdk/pyproject.toml b/packages/python-sdk/pyproject.toml new file mode 100644 index 0000000..3d8ad66 --- /dev/null +++ b/packages/python-sdk/pyproject.toml @@ -0,0 +1,80 @@ +[project] +name = "e2b" +version = "2.31.0" +description = "E2B SDK that give agents cloud environments" +authors = [{ name = "e2b", email = "hello@e2b.dev" }] +license = "MIT" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "python-dateutil>=2.8.2", + "wcmatch>=10.1,<11", + "protobuf>=4.21.0", + "httpcore>=1.0.5,<2", + "httpx>=0.27.0,<1.0.0", + "h2>=4,<5", + "attrs>=23.2.0", + "packaging>=24.1", + "typing-extensions>=4.1.0", + "dockerfile-parse>=2.0.1,<3", + "rich>=14.0.0", +] + +[project.urls] +Homepage = "https://e2b.dev/" +Repository = "https://github.com/e2b-dev/e2b/tree/main/packages/python-sdk" +"Bug Tracker" = "https://github.com/e2b-dev/e2b/issues" + +[dependency-groups] +dev = [ + "pytest>=9.0.3,<10", + "pytest-xdist>=3.3.1,<4", + "python-dotenv>=1.0.0,<2", + "pytest-dotenv>=0.5.2,<0.6", + "pytest-asyncio>=1.3.0,<2", + "ruff>=0.11.12,<0.12", + "pytest-timeout>=2.4.0,<3", + "ty>=0.0.15,<0.0.16", +] + +# Tools for local code generation (`make init` && `uv run make generate-*`). +# Pins mirror codegen.Dockerfile so local output matches CI. `generate-envd` +# additionally needs Go tooling (buf) and only runs via `make codegen` (Docker). +codegen = [ + "black==26.3.1", + "pyyaml==6.0.2", + "e2b-openapi-python-client==0.26.2", + "datamodel-code-generator==0.34.0", +] + +[build-system] +requires = ["uv_build>=0.10.0,<0.11.0"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = ["e2b", "e2b_connect"] +module-root = "" + +[tool.basedpyright] +reportInconsistentOverload = false + +[tool.ty.rules] +invalid-overload = "ignore" +no-matching-overload = "ignore" + +[[tool.ty.overrides]] +include = ["e2b/api/client/models/**"] +rules = { invalid-argument-type = "ignore" } + +[[tool.ty.overrides]] +include = ["e2b/envd/**/*.pyi"] +rules = { conflicting-metaclass = "ignore", unresolved-attribute = "ignore" } + +[[tool.ty.overrides]] +include = ["e2b/volume/**"] +rules = { unresolved-attribute = "ignore" } + +[tool.ruff] +exclude = [ + "e2b/envd/filesystem/filesystem_pb2.py" +] diff --git a/packages/python-sdk/pytest.ini b/packages/python-sdk/pytest.ini new file mode 100644 index 0000000..00dc77b --- /dev/null +++ b/packages/python-sdk/pytest.ini @@ -0,0 +1,13 @@ +# content of pytest.ini +[pytest] +markers = + skip_debug: skip test if E2B_DEBUG is set. + +asyncio_mode=auto +asyncio_default_fixture_loop_scope=session +asyncio_default_test_loop_scope=session +addopts = "--import-mode=importlib" +timeout = 30 +filterwarnings = + ignore:'asyncio\.iscoroutinefunction' is deprecated.*:DeprecationWarning:pytest_asyncio\.plugin + ignore:'asyncio\.get_event_loop_policy' is deprecated.*:DeprecationWarning:pytest_asyncio\.plugin diff --git a/packages/python-sdk/scripts/fix-python-pb.sh b/packages/python-sdk/scripts/fix-python-pb.sh new file mode 100755 index 0000000..7bdea38 --- /dev/null +++ b/packages/python-sdk/scripts/fix-python-pb.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +rm -rf e2b/envd/__pycache__ +rm -rf e2b/envd/filesystem/__pycache__ +rm -rf e2b/envd/process/__pycache__ + +sed -i.bak 's/from\ process\ import/from e2b.envd.process import/g' e2b/envd/process/* e2b/envd/filesystem/* +sed -i.bak 's/from\ filesystem\ import/from e2b.envd.filesystem import/g' e2b/envd/process/* e2b/envd/filesystem/* + +rm -f e2b/envd/process/*.bak +rm -f e2b/envd/filesystem/*.bak diff --git a/packages/python-sdk/tests/async/api_async/test_sbx_info.py b/packages/python-sdk/tests/async/api_async/test_sbx_info.py new file mode 100644 index 0000000..878a582 --- /dev/null +++ b/packages/python-sdk/tests/async/api_async/test_sbx_info.py @@ -0,0 +1,9 @@ +import pytest + +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_get_info(async_sandbox: AsyncSandbox): + info = await AsyncSandbox.get_info(async_sandbox.sandbox_id) + assert info.sandbox_id == async_sandbox.sandbox_id diff --git a/packages/python-sdk/tests/async/api_async/test_sbx_kill.py b/packages/python-sdk/tests/async/api_async/test_sbx_kill.py new file mode 100644 index 0000000..85de035 --- /dev/null +++ b/packages/python-sdk/tests/async/api_async/test_sbx_kill.py @@ -0,0 +1,21 @@ +import pytest + +from e2b import AsyncSandbox, SandboxQuery, SandboxState + + +@pytest.mark.skip_debug() +async def test_kill_existing_sandbox(async_sandbox: AsyncSandbox, sandbox_test_id: str): + assert await AsyncSandbox.kill(async_sandbox.sandbox_id) + + paginator = AsyncSandbox.list( + query=SandboxQuery( + state=[SandboxState.RUNNING], metadata={"sandbox_test_id": sandbox_test_id} + ) + ) + sandboxes = await paginator.next_items() + assert async_sandbox.sandbox_id not in [s.sandbox_id for s in sandboxes] + + +@pytest.mark.skip_debug() +async def test_kill_non_existing_sandbox(): + assert not await AsyncSandbox.kill("nonexistingsandbox") diff --git a/packages/python-sdk/tests/async/api_async/test_sbx_list.py b/packages/python-sdk/tests/async/api_async/test_sbx_list.py new file mode 100644 index 0000000..e902fd9 --- /dev/null +++ b/packages/python-sdk/tests/async/api_async/test_sbx_list.py @@ -0,0 +1,190 @@ +import uuid + +import pytest + +from e2b import AsyncSandbox, SandboxQuery, SandboxState + + +@pytest.mark.skip_debug() +async def test_list_sandboxes(async_sandbox: AsyncSandbox, sandbox_test_id: str): + paginator = AsyncSandbox.list( + query=SandboxQuery(metadata={"sandbox_test_id": sandbox_test_id}) + ) + sandboxes = await paginator.next_items() + assert len(sandboxes) >= 1 + assert async_sandbox.sandbox_id in [sbx.sandbox_id for sbx in sandboxes] + + +@pytest.mark.skip_debug() +async def test_list_sandboxes_with_filter(sandbox_test_id: str, async_sandbox_factory): + unique_id = str(uuid.uuid4()) + extra_sbx = await async_sandbox_factory(metadata={"unique_id": unique_id}) + + paginator = AsyncSandbox.list(query=SandboxQuery(metadata={"unique_id": unique_id})) + sandboxes = await paginator.next_items() + assert len(sandboxes) == 1 + assert sandboxes[0].sandbox_id == extra_sbx.sandbox_id + + +@pytest.mark.skip_debug() +async def test_list_running_sandboxes( + async_sandbox: AsyncSandbox, sandbox_test_id: str +): + paginator = AsyncSandbox.list( + query=SandboxQuery( + metadata={"sandbox_test_id": sandbox_test_id}, state=[SandboxState.RUNNING] + ) + ) + sandboxes = await paginator.next_items() + assert len(sandboxes) >= 1 + + # Verify our running sandbox is in the list + assert any( + s.sandbox_id == async_sandbox.sandbox_id and s.state == SandboxState.RUNNING + for s in sandboxes + ) + + +@pytest.mark.skip_debug() +async def test_list_paused_sandboxes(async_sandbox: AsyncSandbox, sandbox_test_id: str): + await async_sandbox.beta_pause() + + paginator = AsyncSandbox.list( + query=SandboxQuery( + metadata={"sandbox_test_id": sandbox_test_id}, state=[SandboxState.PAUSED] + ) + ) + sandboxes = await paginator.next_items() + assert len(sandboxes) >= 1 + + # Verify our paused sandbox is in the list + assert any( + s.sandbox_id == async_sandbox.sandbox_id and s.state == SandboxState.PAUSED + for s in sandboxes + ) + + +@pytest.mark.skip_debug() +async def test_paginate_running_sandboxes(sandbox_test_id: str, async_sandbox_factory): + sbx1 = await async_sandbox_factory() + sbx2 = await async_sandbox_factory() + + # Test pagination with limit + paginator = AsyncSandbox.list( + query=SandboxQuery( + metadata={"sandbox_test_id": sandbox_test_id}, + state=[SandboxState.RUNNING], + ), + limit=1, + ) + sandboxes = await paginator.next_items() + + # Check first page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.RUNNING + assert paginator.has_next is True + assert paginator.next_token is not None + assert sandboxes[0].sandbox_id == sbx2.sandbox_id + + # Get second page + sandboxes2 = await paginator.next_items() + + # Check second page + assert len(sandboxes2) == 1 + assert sandboxes2[0].state == SandboxState.RUNNING + assert paginator.has_next is False + assert paginator.next_token is None + assert sandboxes2[0].sandbox_id == sbx1.sandbox_id + + +@pytest.mark.skip_debug() +async def test_paginate_paused_sandboxes( + async_sandbox: AsyncSandbox, sandbox_test_id: str, async_sandbox_factory +): + await async_sandbox.beta_pause() + + # create another paused sandbox + extra_sbx = await async_sandbox_factory( + metadata={"sandbox_test_id": sandbox_test_id} + ) + await extra_sbx.beta_pause() + + # Test pagination with limit + paginator = AsyncSandbox.list( + query=SandboxQuery( + state=[SandboxState.PAUSED], + metadata={"sandbox_test_id": sandbox_test_id}, + ), + limit=1, + ) + sandboxes = await paginator.next_items() + + # Check first page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.PAUSED + assert paginator.has_next is True + assert paginator.next_token is not None + assert sandboxes[0].sandbox_id == extra_sbx.sandbox_id + + # Get second page + sandboxes2 = await paginator.next_items() + + # Check second page + assert len(sandboxes2) == 1 + assert sandboxes2[0].state == SandboxState.PAUSED + assert paginator.has_next is False + assert paginator.next_token is None + assert sandboxes2[0].sandbox_id == async_sandbox.sandbox_id + + +@pytest.mark.skip_debug() +async def test_paginate_running_and_paused_sandboxes( + async_sandbox: AsyncSandbox, sandbox_test_id: str, async_sandbox_factory +): + # Create extra paused sandbox + extra_sbx = await async_sandbox_factory( + metadata={"sandbox_test_id": sandbox_test_id} + ) + await extra_sbx.beta_pause() + + # Test pagination with limit + paginator = AsyncSandbox.list( + query=SandboxQuery( + metadata={"sandbox_test_id": sandbox_test_id}, + state=[SandboxState.RUNNING, SandboxState.PAUSED], + ), + limit=1, + ) + sandboxes = await paginator.next_items() + + # Check first page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.PAUSED + assert paginator.has_next is True + assert paginator.next_token is not None + assert sandboxes[0].sandbox_id == extra_sbx.sandbox_id + + # Get second page + sandboxes2 = await paginator.next_items() + + # Check second page + assert len(sandboxes2) == 1 + assert sandboxes2[0].state == SandboxState.RUNNING + assert paginator.has_next is False + assert paginator.next_token is None + assert sandboxes2[0].sandbox_id == async_sandbox.sandbox_id + + +@pytest.mark.skip_debug() +async def test_paginate_iterator(async_sandbox: AsyncSandbox, sandbox_test_id: str): + paginator = AsyncSandbox.list( + query=SandboxQuery(metadata={"sandbox_test_id": sandbox_test_id}) + ) + sandboxes_list = [] + + while paginator.has_next: + sandboxes = await paginator.next_items() + sandboxes_list.extend(sandboxes) + + assert len(sandboxes_list) > 0 + assert async_sandbox.sandbox_id in [sbx.sandbox_id for sbx in sandboxes_list] diff --git a/packages/python-sdk/tests/async/api_async/test_sbx_snapshot.py b/packages/python-sdk/tests/async/api_async/test_sbx_snapshot.py new file mode 100644 index 0000000..49cc2a5 --- /dev/null +++ b/packages/python-sdk/tests/async/api_async/test_sbx_snapshot.py @@ -0,0 +1,19 @@ +import pytest +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_pause_sandbox(async_sandbox: AsyncSandbox): + await AsyncSandbox.pause(async_sandbox.sandbox_id) + assert not await async_sandbox.is_running() + + +@pytest.mark.skip_debug() +async def test_resume_sandbox(async_sandbox: AsyncSandbox): + # pause + await AsyncSandbox.pause(async_sandbox.sandbox_id) + assert not await async_sandbox.is_running() + + # resume + await AsyncSandbox.connect(async_sandbox.sandbox_id) + assert await async_sandbox.is_running() diff --git a/packages/python-sdk/tests/async/sandbox_async/commands/test_cmd_connect.py b/packages/python-sdk/tests/async/sandbox_async/commands/test_cmd_connect.py new file mode 100644 index 0000000..5b08350 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/commands/test_cmd_connect.py @@ -0,0 +1,18 @@ +import pytest + +from e2b import NotFoundException, AsyncSandbox + + +async def test_connect_to_process(async_sandbox: AsyncSandbox): + cmd = await async_sandbox.commands.run("sleep 10", background=True) + pid = cmd.pid + + process_info = await async_sandbox.commands.connect(pid) + assert process_info.pid == pid + + +async def test_connect_to_non_existing_process(async_sandbox: AsyncSandbox): + non_existing_pid = 999999 + + with pytest.raises(NotFoundException): + await async_sandbox.commands.connect(non_existing_pid) diff --git a/packages/python-sdk/tests/async/sandbox_async/commands/test_cmd_kill.py b/packages/python-sdk/tests/async/sandbox_async/commands/test_cmd_kill.py new file mode 100644 index 0000000..a92f9b5 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/commands/test_cmd_kill.py @@ -0,0 +1,19 @@ +import pytest + +from e2b import AsyncSandbox, CommandExitException + + +async def test_kill_process(async_sandbox: AsyncSandbox): + cmd = await async_sandbox.commands.run("sleep 10", background=True) + pid = cmd.pid + + await async_sandbox.commands.kill(pid) + + with pytest.raises(CommandExitException): + await async_sandbox.commands.run(f"kill -0 {pid}") + + +async def test_kill_non_existing_process(async_sandbox: AsyncSandbox): + non_existing_pid = 999999 + + assert not await async_sandbox.commands.kill(non_existing_pid) diff --git a/packages/python-sdk/tests/async/sandbox_async/commands/test_cmd_list.py b/packages/python-sdk/tests/async/sandbox_async/commands/test_cmd_list.py new file mode 100644 index 0000000..b7906a6 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/commands/test_cmd_list.py @@ -0,0 +1,13 @@ +from e2b import AsyncSandbox + + +async def test_kill_process(async_sandbox: AsyncSandbox): + c1 = await async_sandbox.commands.run("sleep 10", background=True) + c2 = await async_sandbox.commands.run("sleep 10", background=True) + + processes = await async_sandbox.commands.list() + + assert len(processes) >= 2 + pids = [p.pid for p in processes] + assert c1.pid in pids + assert c2.pid in pids diff --git a/packages/python-sdk/tests/async/sandbox_async/commands/test_env_vars.py b/packages/python-sdk/tests/async/sandbox_async/commands/test_env_vars.py new file mode 100644 index 0000000..9563cac --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/commands/test_env_vars.py @@ -0,0 +1,35 @@ +import pytest + +from e2b import AsyncSandbox + + +async def test_command_envs(async_sandbox: AsyncSandbox): + cmd = await async_sandbox.commands.run("echo $FOO", envs={"FOO": "bar"}) + assert cmd.stdout.strip() == "bar" + + +@pytest.mark.skip_debug() +async def test_sandbox_envs(async_sandbox_factory): + sbx = await async_sandbox_factory(envs={"FOO": "bar"}) + + cmd = await sbx.commands.run("echo $FOO") + assert cmd.stdout.strip() == "bar" + + +async def test_bash_command_scoped_env_vars(async_sandbox: AsyncSandbox): + cmd = await async_sandbox.commands.run("echo $FOO", envs={"FOO": "bar"}) + assert cmd.exit_code == 0 + assert cmd.stdout.strip() == "bar" + + # test that it is secure and not accessible to subsequent commands + cmd2 = await async_sandbox.commands.run('sudo echo "$FOO"') + assert cmd2.exit_code == 0 + assert cmd2.stdout.strip() == "" + + +async def test_python_command_scoped_env_vars(async_sandbox: AsyncSandbox): + cmd = await async_sandbox.commands.run( + "python3 -c \"import os; print(os.environ['FOO'])\"", envs={"FOO": "bar"} + ) + assert cmd.exit_code == 0 + assert cmd.stdout.strip() == "bar" diff --git a/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py b/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py new file mode 100644 index 0000000..f871ebd --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py @@ -0,0 +1,53 @@ +import pytest + +from e2b import AsyncSandbox, TimeoutException + + +async def test_run(async_sandbox: AsyncSandbox): + text = "Hello, World!" + + cmd = await async_sandbox.commands.run(f'echo "{text}"') + + assert cmd.exit_code == 0 + assert cmd.stdout == f"{text}\n" + + +async def test_run_with_special_characters(async_sandbox: AsyncSandbox): + text = "!@#$%^&*()_+" + + cmd = await async_sandbox.commands.run(f'echo "{text}"') + + assert cmd.exit_code == 0 + + +# assert cmd.stdout == f"{text}\n" + + +async def test_run_with_broken_utf8(async_sandbox: AsyncSandbox): + # Create a string with 8191 'a' characters followed by the problematic byte 0xe2 + long_str = "a" * 8191 + "\\xe2" + result = await async_sandbox.commands.run(f'printf "{long_str}"') + assert result.exit_code == 0 + + # The broken UTF-8 bytes should be replaced with the Unicode replacement character + assert result.stdout == ("a" * 8191 + "\ufffd") + + +async def test_run_with_multiline_string(async_sandbox: AsyncSandbox): + text = "Hello,\nWorld!" + + cmd = await async_sandbox.commands.run(f'echo "{text}"') + + assert cmd.exit_code == 0 + assert cmd.stdout == f"{text}\n" + + +async def test_run_with_timeout(async_sandbox: AsyncSandbox): + cmd = await async_sandbox.commands.run('echo "Hello, World!"', timeout=10) + + assert cmd.exit_code == 0 + + +async def test_run_with_too_short_timeout(async_sandbox: AsyncSandbox): + with pytest.raises(TimeoutException): + await async_sandbox.commands.run("sleep 10", timeout=2) diff --git a/packages/python-sdk/tests/async/sandbox_async/commands/test_sandbox_killed_during_run.py b/packages/python-sdk/tests/async/sandbox_async/commands/test_sandbox_killed_during_run.py new file mode 100644 index 0000000..2f83f21 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/commands/test_sandbox_killed_during_run.py @@ -0,0 +1,16 @@ +import pytest + +from e2b import AsyncSandbox, TimeoutException + + +@pytest.mark.skip_debug() +async def test_kill_sandbox_while_command_is_running(async_sandbox: AsyncSandbox): + cmd = await async_sandbox.commands.run("sleep 60", background=True) + + await async_sandbox.kill() + + with pytest.raises(TimeoutException) as exc_info: + await cmd.wait() + + # The health check confirms the sandbox is gone, so the error states it outright + assert "sandbox was killed or reached its end of life" in str(exc_info.value) diff --git a/packages/python-sdk/tests/async/sandbox_async/commands/test_send_stdin.py b/packages/python-sdk/tests/async/sandbox_async/commands/test_send_stdin.py new file mode 100644 index 0000000..58c4c94 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/commands/test_send_stdin.py @@ -0,0 +1,94 @@ +import asyncio + +from e2b import AsyncSandbox + + +async def test_send_stdin_to_process(async_sandbox: AsyncSandbox): + ev = asyncio.Event() + + def handle_event(stdout: str): + ev.set() + + cmd = await async_sandbox.commands.run( + "cat", background=True, on_stdout=handle_event, stdin=True + ) + await async_sandbox.commands.send_stdin(cmd.pid, "Hello, World!") + + await ev.wait() + + assert cmd.stdout == "Hello, World!" + + +async def test_send_bytes_stdin_to_process(async_sandbox: AsyncSandbox): + ev = asyncio.Event() + + def handle_event(stdout: str): + ev.set() + + cmd = await async_sandbox.commands.run( + "cat", background=True, on_stdout=handle_event, stdin=True + ) + await async_sandbox.commands.send_stdin(cmd.pid, b"Hello, World!") + + await ev.wait() + + assert cmd.stdout == "Hello, World!" + + +async def test_send_stdin_via_command_handle(async_sandbox: AsyncSandbox): + ev = asyncio.Event() + + def handle_event(stdout: str): + ev.set() + + cmd = await async_sandbox.commands.run( + "cat", background=True, on_stdout=handle_event, stdin=True + ) + await cmd.send_stdin("Hello, World!") + + await ev.wait() + + assert cmd.stdout == "Hello, World!" + + +async def test_close_stdin_via_command_handle(async_sandbox: AsyncSandbox): + cmd = await async_sandbox.commands.run("cat", background=True, stdin=True) + await cmd.send_stdin("Hello, World!") + await cmd.close_stdin() + + # `cat` exits once stdin is closed (EOF). + result = await cmd.wait() + assert result.exit_code == 0 + assert result.stdout == "Hello, World!" + + +async def test_send_special_characters_to_process(async_sandbox: AsyncSandbox): + ev = asyncio.Event() + + def handle_event(stdout: str): + ev.set() + + cmd = await async_sandbox.commands.run( + "cat", background=True, on_stdout=handle_event, stdin=True + ) + await async_sandbox.commands.send_stdin(cmd.pid, "!@#$%^&*()_+") + + await ev.wait() + + assert cmd.stdout == "!@#$%^&*()_+" + + +async def test_send_multiline_string_to_process(async_sandbox: AsyncSandbox): + ev = asyncio.Event() + + def handle_event(stdout: str): + ev.set() + + cmd = await async_sandbox.commands.run( + "cat", background=True, on_stdout=handle_event, stdin=True + ) + await async_sandbox.commands.send_stdin(cmd.pid, "Hello,\nWorld!") + + await ev.wait() + + assert cmd.stdout == "Hello,\nWorld!" diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_content_encoding.py b/packages/python-sdk/tests/async/sandbox_async/files/test_content_encoding.py new file mode 100644 index 0000000..c6b2df8 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_content_encoding.py @@ -0,0 +1,62 @@ +from e2b import AsyncSandbox +from e2b.sandbox.filesystem.filesystem import WriteEntry + + +async def test_write_and_read_with_gzip(async_sandbox: AsyncSandbox, debug): + filename = "test_gzip_write.txt" + content = "This is a test file with gzip encoding." + + info = await async_sandbox.files.write(filename, content, gzip=True) + assert info.path == f"/home/user/{filename}" + + read_content = await async_sandbox.files.read(filename, gzip=True) + assert read_content == content + + if debug: + await async_sandbox.files.remove(filename) + + +async def test_write_gzip_read_plain(async_sandbox: AsyncSandbox, debug): + filename = "test_gzip_write_plain_read.txt" + content = "Written with gzip, read without." + + await async_sandbox.files.write(filename, content, gzip=True) + + read_content = await async_sandbox.files.read(filename) + assert read_content == content + + if debug: + await async_sandbox.files.remove(filename) + + +async def test_write_files_with_gzip(async_sandbox: AsyncSandbox, debug): + files = [ + WriteEntry(path="gzip_multi_1.txt", data="File 1 content"), + WriteEntry(path="gzip_multi_2.txt", data="File 2 content"), + WriteEntry(path="gzip_multi_3.txt", data="File 3 content"), + ] + + infos = await async_sandbox.files.write_files(files, gzip=True) + assert len(infos) == len(files) + + for file in files: + read_content = await async_sandbox.files.read(file["path"]) + assert read_content == file["data"] + + if debug: + for file in files: + await async_sandbox.files.remove(file["path"]) + + +async def test_read_bytes_with_gzip(async_sandbox: AsyncSandbox, debug): + filename = "test_gzip_bytes.txt" + content = "Binary content with gzip." + + await async_sandbox.files.write(filename, content) + + read_bytes = await async_sandbox.files.read(filename, format="bytes", gzip=True) + assert isinstance(read_bytes, bytearray) + assert read_bytes.decode("utf-8") == content + + if debug: + await async_sandbox.files.remove(filename) diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_exists.py b/packages/python-sdk/tests/async/sandbox_async/files/test_exists.py new file mode 100644 index 0000000..203dd19 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_exists.py @@ -0,0 +1,8 @@ +from e2b import AsyncSandbox + + +async def test_exists(async_sandbox: AsyncSandbox): + filename = "test_exists.txt" + + await async_sandbox.files.write(filename, "test") + assert await async_sandbox.files.exists(filename) diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_files_list.py b/packages/python-sdk/tests/async/sandbox_async/files/test_files_list.py new file mode 100644 index 0000000..12daab0 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_files_list.py @@ -0,0 +1,248 @@ +import uuid +from typing import Any + +from e2b import AsyncSandbox, FileType + + +async def test_list_directory(async_sandbox: AsyncSandbox): + home_dir_name = "/home/user" + parent_dir_name = f"test_directory_{uuid.uuid4()}" + + await async_sandbox.files.make_dir(parent_dir_name) + await async_sandbox.files.make_dir(f"{parent_dir_name}/subdir1") + await async_sandbox.files.make_dir(f"{parent_dir_name}/subdir2") + await async_sandbox.files.make_dir(f"{parent_dir_name}/subdir1/subdir1_1") + await async_sandbox.files.make_dir(f"{parent_dir_name}/subdir1/subdir1_2") + await async_sandbox.files.make_dir(f"{parent_dir_name}/subdir2/subdir2_1") + await async_sandbox.files.make_dir(f"{parent_dir_name}/subdir2/subdir2_2") + await async_sandbox.files.write(f"{parent_dir_name}/file1.txt", "Hello, world!") + + test_cases: list[dict[str, Any]] = [ + { + "name": "default depth (1)", + "depth": None, + "expected_len": 3, + "expected_file_names": [ + "file1.txt", + "subdir1", + "subdir2", + ], + "expected_file_types": [ + FileType.FILE, + FileType.DIR, + FileType.DIR, + ], + "expected_file_paths": [ + f"{home_dir_name}/{parent_dir_name}/file1.txt", + f"{home_dir_name}/{parent_dir_name}/subdir1", + f"{home_dir_name}/{parent_dir_name}/subdir2", + ], + }, + { + "name": "explicit depth 1", + "depth": 1, + "expected_len": 3, + "expected_file_names": [ + "file1.txt", + "subdir1", + "subdir2", + ], + "expected_file_types": [ + FileType.FILE, + FileType.DIR, + FileType.DIR, + ], + "expected_file_paths": [ + f"{home_dir_name}/{parent_dir_name}/file1.txt", + f"{home_dir_name}/{parent_dir_name}/subdir1", + f"{home_dir_name}/{parent_dir_name}/subdir2", + ], + }, + { + "name": "explicit depth 2", + "depth": 2, + "expected_len": 7, + "expected_file_types": [ + FileType.FILE, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + ], + "expected_file_names": [ + "file1.txt", + "subdir1", + "subdir1_1", + "subdir1_2", + "subdir2", + "subdir2_1", + "subdir2_2", + ], + "expected_file_paths": [ + f"{home_dir_name}/{parent_dir_name}/file1.txt", + f"{home_dir_name}/{parent_dir_name}/subdir1", + f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_1", + f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_2", + f"{home_dir_name}/{parent_dir_name}/subdir2", + f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_1", + f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_2", + ], + }, + { + "name": "explicit depth 3 (should be the same as depth 2)", + "depth": 3, + "expected_len": 7, + "expected_file_names": [ + "file1.txt", + "subdir1", + "subdir1_1", + "subdir1_2", + "subdir2", + "subdir2_1", + "subdir2_2", + ], + "expected_file_types": [ + FileType.FILE, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + ], + "expected_file_paths": [ + f"{home_dir_name}/{parent_dir_name}/file1.txt", + f"{home_dir_name}/{parent_dir_name}/subdir1", + f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_1", + f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_2", + f"{home_dir_name}/{parent_dir_name}/subdir2", + f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_1", + f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_2", + ], + }, + ] + + for test_case in test_cases: + files = await async_sandbox.files.list( + parent_dir_name, + depth=test_case["depth"] if test_case["depth"] is not None else None, + ) + + assert len(files) == test_case["expected_len"] + + for i in range(len(test_case["expected_file_names"])): + assert files[i].name == test_case["expected_file_names"][i] + assert files[i].path == test_case["expected_file_paths"][i] + assert files[i].type == test_case["expected_file_types"][i] + + await async_sandbox.files.remove(parent_dir_name) + + +async def test_list_directory_error_cases(async_sandbox: AsyncSandbox): + parent_dir_name = f"test_directory_{uuid.uuid4()}" + await async_sandbox.files.make_dir(parent_dir_name) + + expected_error_message = "depth should be at least 1" + try: + await async_sandbox.files.list(parent_dir_name, depth=-1) + assert False, "Expected error but none was thrown" + except Exception as err: + assert expected_error_message in str(err), ( + f'expected error message to include "{expected_error_message}"' + ) + + await async_sandbox.files.remove(parent_dir_name) + + +async def test_file_entry_details(async_sandbox: AsyncSandbox): + test_dir = "test-file-entry" + file_path = f"{test_dir}/test.txt" + content = "Hello, World!" + + await async_sandbox.files.make_dir(test_dir) + await async_sandbox.files.write(file_path, content) + + files = await async_sandbox.files.list(test_dir, depth=1) + assert len(files) == 1 + + file_entry = files[0] + assert file_entry.name == "test.txt" + assert file_entry.path == f"/home/user/{file_path}" + assert file_entry.type == FileType.FILE + assert file_entry.mode == 0o644 + assert file_entry.permissions == "-rw-r--r--" + assert file_entry.owner == "user" + assert file_entry.group == "user" + assert file_entry.size == len(content) + assert file_entry.modified_time is not None + assert file_entry.symlink_target is None + + await async_sandbox.files.remove(test_dir) + + +async def test_directory_entry_details(async_sandbox: AsyncSandbox): + test_dir = "test-entry-info" + sub_dir = f"{test_dir}/subdir" + + await async_sandbox.files.make_dir(test_dir) + await async_sandbox.files.make_dir(sub_dir) + + files = await async_sandbox.files.list(test_dir, depth=1) + assert len(files) == 1 + + dir_entry = files[0] + assert dir_entry.name == "subdir" + assert dir_entry.path == f"/home/user/{sub_dir}" + assert dir_entry.type == FileType.DIR + assert dir_entry.mode == 0o755 + assert dir_entry.permissions == "drwxr-xr-x" + assert dir_entry.owner == "user" + assert dir_entry.group == "user" + assert dir_entry.modified_time is not None + assert dir_entry.symlink_target is None + + await async_sandbox.files.remove(test_dir) + + +async def test_mixed_entries(async_sandbox: AsyncSandbox): + test_dir = "test-mixed-entries" + sub_dir = f"{test_dir}/subdir" + file_path = f"{test_dir}/test.txt" + content = "Hello, World!" + + await async_sandbox.files.make_dir(test_dir) + await async_sandbox.files.make_dir(sub_dir) + await async_sandbox.files.write(file_path, content) + + files = await async_sandbox.files.list(test_dir, depth=1) + assert len(files) == 2 + + # Create a dictionary of entries by name for easier verification + entries = {entry.name: entry for entry in files} + + # Verify directory entry + dir_entry = entries.get("subdir") + assert dir_entry is not None + assert dir_entry.path == f"/home/user/{sub_dir}" + assert dir_entry.type == FileType.DIR + assert dir_entry.mode == 0o755 + assert dir_entry.permissions == "drwxr-xr-x" + assert dir_entry.owner == "user" + assert dir_entry.group == "user" + assert dir_entry.modified_time is not None + + # Verify file entry + file_entry = entries.get("test.txt") + assert file_entry is not None + assert file_entry.path == f"/home/user/{file_path}" + assert file_entry.type == FileType.FILE + assert file_entry.mode == 0o644 + assert file_entry.permissions == "-rw-r--r--" + assert file_entry.owner == "user" + assert file_entry.group == "user" + assert file_entry.size == len(content) + assert file_entry.modified_time is not None + + await async_sandbox.files.remove(test_dir) diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_info.py b/packages/python-sdk/tests/async/sandbox_async/files/test_info.py new file mode 100644 index 0000000..78156de --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_info.py @@ -0,0 +1,79 @@ +import pytest +from e2b.exceptions import FileNotFoundException +from e2b import AsyncSandbox, FileType + + +@pytest.mark.asyncio +async def test_get_info_of_file(async_sandbox: AsyncSandbox): + filename = "test_file.txt" + + await async_sandbox.files.write(filename, "test") + info = await async_sandbox.files.get_info(filename) + current_path = await async_sandbox.commands.run("pwd") + + assert info.name == filename + assert info.type == FileType.FILE + assert info.path == f"{current_path.stdout.strip()}/{filename}" + assert info.size == 4 + assert info.mode == 0o644 + assert info.permissions == "-rw-r--r--" + assert info.owner == "user" + assert info.group == "user" + assert info.modified_time is not None + assert info.modified_time.tzinfo is not None + + +@pytest.mark.asyncio +async def test_get_info_of_nonexistent_file(async_sandbox: AsyncSandbox): + filename = "test_does_not_exist.txt" + + with pytest.raises(FileNotFoundException): + await async_sandbox.files.get_info(filename) + + +@pytest.mark.asyncio +async def test_get_info_of_directory(async_sandbox: AsyncSandbox): + dirname = "test_dir" + + await async_sandbox.files.make_dir(dirname) + info = await async_sandbox.files.get_info(dirname) + current_path = await async_sandbox.commands.run("pwd") + + assert info.name == dirname + assert info.type == FileType.DIR + assert info.path == f"{current_path.stdout.strip()}/{dirname}" + assert info.size > 0 + assert info.mode == 0o755 + assert info.permissions == "drwxr-xr-x" + assert info.owner == "user" + assert info.group == "user" + assert info.modified_time is not None + assert info.modified_time.tzinfo is not None + + +@pytest.mark.asyncio +async def test_get_info_of_nonexistent_directory(async_sandbox: AsyncSandbox): + dirname = "test_does_not_exist_dir" + + with pytest.raises(FileNotFoundException): + await async_sandbox.files.get_info(dirname) + + +async def test_file_symlink(async_sandbox: AsyncSandbox): + test_dir = "test-simlink-entry" + file_name = "test.txt" + content = "Hello, World!" + + await async_sandbox.files.make_dir(test_dir) + await async_sandbox.files.write(f"{test_dir}/{file_name}", content) + + symlink_name = "symlink_to_test.txt" + await async_sandbox.commands.run(f"ln -s {file_name} {symlink_name}", cwd=test_dir) + + file = await async_sandbox.files.get_info(f"{test_dir}/{symlink_name}") + + pwd = await async_sandbox.commands.run("pwd") + assert file.type == FileType.FILE + assert file.symlink_target == f"{pwd.stdout.strip()}/{test_dir}/{file_name}" + + await async_sandbox.files.remove(test_dir) diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_make_dir.py b/packages/python-sdk/tests/async/sandbox_async/files/test_make_dir.py new file mode 100644 index 0000000..d5c7230 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_make_dir.py @@ -0,0 +1,29 @@ +import uuid + +from e2b import AsyncSandbox + + +async def test_make_directory(async_sandbox: AsyncSandbox): + dir_name = f"test_directory_{uuid.uuid4()}" + + await async_sandbox.files.make_dir(dir_name) + exists = await async_sandbox.files.exists(dir_name) + assert exists + + +async def test_make_directory_already_exists(async_sandbox: AsyncSandbox): + dir_name = f"test_directory_{uuid.uuid4()}" + + created = await async_sandbox.files.make_dir(dir_name) + assert created + + created = await async_sandbox.files.make_dir(dir_name) + assert not created + + +async def test_make_nested_directory(async_sandbox: AsyncSandbox): + nested_dir_name = f"test_directory_{uuid.uuid4()}/nested_directory" + + await async_sandbox.files.make_dir(nested_dir_name) + exists = await async_sandbox.files.exists(nested_dir_name) + assert exists diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py b/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py new file mode 100644 index 0000000..99c7060 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_metadata.py @@ -0,0 +1,172 @@ +import pytest + +from e2b import AsyncSandbox +from e2b.exceptions import InvalidArgumentException +from e2b.sandbox.filesystem.filesystem import WriteEntry + + +async def test_write_file_with_metadata(async_sandbox: AsyncSandbox, debug): + filename = "test_metadata.txt" + content = "This is a test file with metadata." + metadata = {"author": "mish", "purpose": "upload"} + + info = await async_sandbox.files.write(filename, content, metadata=metadata) + assert info.metadata == metadata + + # Metadata is persisted and surfaced on subsequent reads. + stat = await async_sandbox.files.get_info(filename) + assert stat.metadata == metadata + + if debug: + await async_sandbox.files.remove(filename) + + +async def test_write_file_with_metadata_octet_stream( + async_sandbox: AsyncSandbox, debug +): + filename = "test_metadata_octet.txt" + content = "This is a test file with metadata." + metadata = {"author": "mish", "purpose": "upload"} + + info = await async_sandbox.files.write( + filename, content, metadata=metadata, use_octet_stream=True + ) + assert info.metadata == metadata + + stat = await async_sandbox.files.get_info(filename) + assert stat.metadata == metadata + + if debug: + await async_sandbox.files.remove(filename) + + +async def test_write_file_without_metadata(async_sandbox: AsyncSandbox, debug): + filename = "test_no_metadata.txt" + + info = await async_sandbox.files.write(filename, "no metadata here") + assert info.metadata is None + + stat = await async_sandbox.files.get_info(filename) + assert stat.metadata is None + + if debug: + await async_sandbox.files.remove(filename) + + +async def test_write_files_applies_metadata_to_every_file( + async_sandbox: AsyncSandbox, debug +): + # The same metadata is applied to every file in the upload. + metadata = {"source": "test-suite"} + files = [ + WriteEntry(path="metadata_multi_1.txt", data="File 1"), + WriteEntry(path="metadata_multi_2.txt", data="File 2"), + ] + + infos = await async_sandbox.files.write_files(files, metadata=metadata) + assert len(infos) == len(files) + + for info in infos: + assert info.metadata == metadata + stat = await async_sandbox.files.get_info(info.path) + assert stat.metadata == metadata + + if debug: + for file in files: + await async_sandbox.files.remove(file["path"]) + + +async def test_metadata_surfaced_when_listing(async_sandbox: AsyncSandbox, debug): + dirname = "metadata_list_dir" + filename = "listed.txt" + metadata = {"tag": "listed"} + + await async_sandbox.files.make_dir(dirname) + await async_sandbox.files.write( + f"{dirname}/{filename}", "content", metadata=metadata + ) + + entries = await async_sandbox.files.list(dirname) + entry = next((e for e in entries if e.name == filename), None) + assert entry is not None + assert entry.metadata == metadata + + if debug: + await async_sandbox.files.remove(dirname) + + +async def test_metadata_surfaced_after_rename(async_sandbox: AsyncSandbox, debug): + old_path = "metadata_rename_old.txt" + new_path = "metadata_rename_new.txt" + metadata = {"stage": "renamed"} + + await async_sandbox.files.write(old_path, "content", metadata=metadata) + info = await async_sandbox.files.rename(old_path, new_path) + assert info.metadata == metadata + + if debug: + await async_sandbox.files.remove(new_path) + + +async def test_overwriting_clears_stale_metadata(async_sandbox: AsyncSandbox, debug): + filename = "metadata_overwrite.txt" + + await async_sandbox.files.write(filename, "first", metadata={"author": "mish"}) + + # Overwriting without metadata removes the previously stored metadata. + info = await async_sandbox.files.write(filename, "second") + assert info.metadata is None + + stat = await async_sandbox.files.get_info(filename) + assert stat.metadata is None + + if debug: + await async_sandbox.files.remove(filename) + + +async def test_metadata_set_via_xattrs_surfaced_in_get_info( + async_sandbox: AsyncSandbox, debug +): + filename = "metadata_xattr.txt" + await async_sandbox.files.write(filename, "content") + + cmd = await async_sandbox.commands.run(f"realpath {filename}") + file_path = cmd.stdout.strip() + + # Set an xattr directly in the `user.e2b.` namespace (out-of-band, not via + # the SDK upload); it should surface as metadata (with the namespace prefix + # stripped) when reading the file info. + await async_sandbox.commands.run( + f"python3 -c \"import os; os.setxattr('{file_path}', 'user.e2b.author', b'mish')\"" + ) + + info = await async_sandbox.files.get_info(filename) + assert info.metadata == {"author": "mish"} + + if debug: + await async_sandbox.files.remove(filename) + + +async def test_write_rejects_invalid_metadata(async_sandbox: AsyncSandbox): + filename = "invalid_metadata.txt" + + # Key with a space is not a valid HTTP header token. + with pytest.raises(InvalidArgumentException): + await async_sandbox.files.write(filename, "x", metadata={"bad key": "value"}) + + # Empty key. + with pytest.raises(InvalidArgumentException): + await async_sandbox.files.write(filename, "x", metadata={"": "value"}) + + # Value with a non-printable / non-ASCII character. + with pytest.raises(InvalidArgumentException): + await async_sandbox.files.write(filename, "x", metadata={"good": "bad\nvalue"}) + + # Trailing newline (Python's `$` would accept it; `\Z` must not). + with pytest.raises(InvalidArgumentException): + await async_sandbox.files.write(filename, "x", metadata={"good": "value\n"}) + with pytest.raises(InvalidArgumentException): + await async_sandbox.files.write(filename, "x", metadata={"key\n": "value"}) + + # The file must not have been created by a rejected write. + assert not await async_sandbox.files.exists(filename) diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_read.py b/packages/python-sdk/tests/async/sandbox_async/files/test_read.py new file mode 100644 index 0000000..89db688 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_read.py @@ -0,0 +1,95 @@ +import pytest + +from e2b import FileNotFoundException, NotFoundException, AsyncSandbox + + +async def test_read_file(async_sandbox: AsyncSandbox): + filename = "test_read.txt" + content = "Hello, world!" + + await async_sandbox.files.write(filename, content) + read_content = await async_sandbox.files.read(filename) + assert read_content == content + + +async def test_read_non_existing_file(async_sandbox: AsyncSandbox): + filename = "non_existing_file.txt" + + with pytest.raises(FileNotFoundException): + await async_sandbox.files.read(filename) + + +async def test_read_non_existing_file_catches_with_deprecated_not_found_exception( + async_sandbox: AsyncSandbox, +): + filename = "non_existing_file.txt" + + with pytest.raises(NotFoundException): + await async_sandbox.files.read(filename) + + +async def test_read_empty_file(async_sandbox: AsyncSandbox): + filename = "empty_file.txt" + content = "" + + await async_sandbox.commands.run(f"touch {filename}") + read_content = await async_sandbox.files.read(filename) + assert read_content == content + + +async def test_read_file_as_stream(async_sandbox: AsyncSandbox): + filename = "test_read_stream.txt" + content = "Streamed read content. " * 10_000 + + await async_sandbox.files.write(filename, content) + stream = await async_sandbox.files.read(filename, format="stream") + chunks = [] + async for chunk in stream: + chunks.append(chunk) + read_content = b"".join(chunks).decode("utf-8") + assert read_content == content + + +async def test_read_file_as_stream_with_gzip(async_sandbox: AsyncSandbox): + filename = "test_read_stream_gzip.txt" + content = "Streamed gzipped read content. " * 10_000 + + await async_sandbox.files.write(filename, content) + stream = await async_sandbox.files.read(filename, format="stream", gzip=True) + chunks = [] + async for chunk in stream: + chunks.append(chunk) + read_content = b"".join(chunks).decode("utf-8") + assert read_content == content + + +async def test_read_non_existing_file_as_stream(async_sandbox: AsyncSandbox): + filename = "non_existing_file.txt" + + with pytest.raises(FileNotFoundException): + await async_sandbox.files.read(filename, format="stream") + + +async def test_read_file_as_stream_context_manager(async_sandbox: AsyncSandbox): + filename = "test_read_stream_ctx.txt" + content = "Streamed read content. " * 10_000 + + await async_sandbox.files.write(filename, content) + chunks = [] + async with await async_sandbox.files.read(filename, format="stream") as stream: + async for chunk in stream: + chunks.append(chunk) + read_content = b"".join(chunks).decode("utf-8") + assert read_content == content + + +async def test_read_file_as_stream_partial_then_close(async_sandbox: AsyncSandbox): + filename = "test_read_stream_partial.txt" + content = "Streamed read content. " * 10_000 + + await async_sandbox.files.write(filename, content) + # Reading only the first chunk and closing must not raise or leak. + stream = await async_sandbox.files.read(filename, format="stream") + first = await stream.__anext__() + assert len(first) > 0 + await stream.aclose() diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_remove.py b/packages/python-sdk/tests/async/sandbox_async/files/test_remove.py new file mode 100644 index 0000000..07cf2cf --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_remove.py @@ -0,0 +1,18 @@ +from e2b import AsyncSandbox + + +async def test_remove_file(async_sandbox: AsyncSandbox): + filename = "test_remove.txt" + content = "This file will be removed." + + await async_sandbox.files.write(filename, content) + + await async_sandbox.files.remove(filename) + + exists = await async_sandbox.files.exists(filename) + assert not exists + + +async def test_remove_non_existing_file(async_sandbox: AsyncSandbox): + filename = "non_existing_file.txt" + await async_sandbox.files.remove(filename) diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_rename.py b/packages/python-sdk/tests/async/sandbox_async/files/test_rename.py new file mode 100644 index 0000000..626b3a7 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_rename.py @@ -0,0 +1,29 @@ +import pytest + +from e2b import FileNotFoundException, AsyncSandbox + + +async def test_rename_file(async_sandbox: AsyncSandbox): + old_filename = "test_rename_old.txt" + new_filename = "test_rename_new.txt" + content = "This file will be renamed." + + await async_sandbox.files.write(old_filename, content) + info = await async_sandbox.files.rename(old_filename, new_filename) + assert info.path == f"/home/user/{new_filename}" + + exists_old = await async_sandbox.files.exists(old_filename) + exists_new = await async_sandbox.files.exists(new_filename) + assert not exists_old + assert exists_new + + read_content = await async_sandbox.files.read(new_filename) + assert read_content == content + + +async def test_rename_non_existing_file(async_sandbox: AsyncSandbox): + old_filename = "non_existing_file.txt" + new_filename = "new_non_existing_file.txt" + + with pytest.raises(FileNotFoundException): + await async_sandbox.files.rename(old_filename, new_filename) diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_secured.py b/packages/python-sdk/tests/async/sandbox_async/files/test_secured.py new file mode 100644 index 0000000..2f904d0 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_secured.py @@ -0,0 +1,60 @@ +import urllib.request +import urllib.error +import json +import pytest + +from e2b.sandbox_async.main import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_download_url_with_signing(async_sandbox: AsyncSandbox): + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + await async_sandbox.files.write(file_path, file_content) + signed_url = async_sandbox.download_url(file_path, "user") + + with urllib.request.urlopen(signed_url) as resp: + assert resp.status == 200 + body_bytes = resp.read() + body_text = body_bytes.decode() + assert body_text == file_content + + +@pytest.mark.skip_debug() +async def test_download_url_with_signing_and_expiration(async_sandbox: AsyncSandbox): + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + await async_sandbox.files.write(file_path, file_content) + signed_url = async_sandbox.download_url(file_path, "user", 120) + + with urllib.request.urlopen(signed_url) as resp: + assert resp.status == 200 + body_bytes = resp.read() + body_text = body_bytes.decode() + assert body_text == file_content + + +@pytest.mark.skip_debug() +async def test_download_url_with_expired_signing(async_sandbox: AsyncSandbox): + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + await async_sandbox.files.write(file_path, file_content) + + signed_url = async_sandbox.download_url( + file_path, "user", use_signature_expiration=-120 + ) + + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(signed_url) + + err = exc_info.value + assert err.code == 401, f"Unexpected status {err.code}" + + error_json_str = err.read().decode() # bytes ➜ str + error_payload = json.loads(error_json_str) # str ➜ dict + + expected_payload = {"code": 401, "message": "signature is already expired"} + assert error_payload == expected_payload diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_watch.py b/packages/python-sdk/tests/async/sandbox_async/files/test_watch.py new file mode 100644 index 0000000..a55a9f4 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_watch.py @@ -0,0 +1,185 @@ +import pytest + +from asyncio import Event + +from e2b import ( + FileNotFoundException, + AsyncSandbox, + FilesystemEvent, + FilesystemEventType, + FileType, + SandboxException, +) + + +async def test_watch_directory_changes_with_entry_info(async_sandbox: AsyncSandbox): + dirname = "test_watch_dir_entry" + filename = "test_watch.txt" + content = "This file will be watched." + new_content = "This file has been modified." + + await async_sandbox.files.make_dir(dirname) + await async_sandbox.files.write(f"{dirname}/{filename}", content) + + event_triggered = Event() + received: list[FilesystemEvent] = [] + + def handle_event(e: FilesystemEvent): + if e.type == FilesystemEventType.WRITE and e.name == filename: + received.append(e) + event_triggered.set() + + handle = await async_sandbox.files.watch_dir( + dirname, on_event=handle_event, include_entry=True + ) + + await async_sandbox.files.write(f"{dirname}/{filename}", new_content) + + await event_triggered.wait() + + write_event = received[0] + # The entry is populated best-effort for events where the path still exists. + assert write_event.entry is not None + assert write_event.entry.name == filename + assert write_event.entry.path == f"/home/user/{dirname}/{filename}" + assert write_event.entry.type == FileType.FILE + + await handle.stop() + + +async def test_watch_directory_changes_with_network_mounts_allowed( + async_sandbox: AsyncSandbox, +): + dirname = "test_watch_dir_network_mounts" + filename = "test_watch.txt" + content = "This file will be watched." + new_content = "This file has been modified." + + await async_sandbox.files.make_dir(dirname) + await async_sandbox.files.write(f"{dirname}/{filename}", content) + + event_triggered = Event() + + def handle_event(e: FilesystemEvent): + if e.type == FilesystemEventType.WRITE and e.name == filename: + event_triggered.set() + + # The flag only lifts the network-mount restriction — watching a regular + # directory must work the same with it enabled. + handle = await async_sandbox.files.watch_dir( + dirname, on_event=handle_event, allow_network_mounts=True + ) + + await async_sandbox.files.write(f"{dirname}/{filename}", new_content) + + await event_triggered.wait() + + await handle.stop() + + +async def test_watch_directory_changes(async_sandbox: AsyncSandbox): + dirname = "test_watch_dir" + filename = "test_watch.txt" + content = "This file will be watched." + new_content = "This file has been modified." + + await async_sandbox.files.make_dir(dirname) + await async_sandbox.files.write(f"{dirname}/{filename}", content) + + event_triggered = Event() + + def handle_event(e: FilesystemEvent): + if e.type == FilesystemEventType.WRITE and e.name == filename: + event_triggered.set() + + handle = await async_sandbox.files.watch_dir(dirname, on_event=handle_event) + + await async_sandbox.files.write(f"{dirname}/{filename}", new_content) + + await event_triggered.wait() + + await handle.stop() + + +async def test_watch_recursive_directory_changes(async_sandbox: AsyncSandbox): + dirname = "test_recursive_watch_dir" + nested_dirname = "test_nested_watch_dir" + filename = "test_watch.txt" + content = "This file will be watched." + + await async_sandbox.files.remove(dirname) + await async_sandbox.files.make_dir(f"{dirname}/{nested_dirname}") + + event_triggered = Event() + + expected_filename = f"{nested_dirname}/{filename}" + + def handle_event(e: FilesystemEvent): + if e.type == FilesystemEventType.WRITE and e.name == expected_filename: + event_triggered.set() + + handle = await async_sandbox.files.watch_dir( + dirname, on_event=handle_event, recursive=True + ) + + await async_sandbox.files.write(f"{dirname}/{nested_dirname}/{filename}", content) + + await event_triggered.wait() + + await handle.stop() + + +async def test_watch_recursive_directory_after_nested_folder_addition( + async_sandbox: AsyncSandbox, +): + dirname = "test_recursive_watch_dir_add" + nested_dirname = "test_nested_watch_dir" + filename = "test_watch.txt" + content = "This file will be watched." + + await async_sandbox.files.remove(dirname) + await async_sandbox.files.make_dir(dirname) + + event_triggered_file = Event() + event_triggered_folder = Event() + + expected_filename = f"{nested_dirname}/{filename}" + + def handle_event(e: FilesystemEvent): + if e.type == FilesystemEventType.WRITE and e.name == expected_filename: + event_triggered_file.set() + return + if e.type == FilesystemEventType.CREATE and e.name == nested_dirname: + event_triggered_folder.set() + + handle = await async_sandbox.files.watch_dir( + dirname, on_event=handle_event, recursive=True + ) + + await async_sandbox.files.make_dir(f"{dirname}/{nested_dirname}") + await event_triggered_folder.wait() + + await async_sandbox.files.write(f"{dirname}/{nested_dirname}/{filename}", content) + await event_triggered_file.wait() + + await handle.stop() + + +async def test_watch_non_existing_directory(async_sandbox: AsyncSandbox): + dirname = "non_existing_watch_dir" + + with pytest.raises(FileNotFoundException): + await async_sandbox.files.watch_dir(dirname, on_event=lambda e: None) + + +async def test_watch_file(async_sandbox: AsyncSandbox): + filename = "test_watch.txt" + await async_sandbox.files.write(filename, "This file will be watched.") + + with pytest.raises(SandboxException): + await async_sandbox.files.watch_dir(filename, on_event=lambda e: None) + + +async def test_watch_file_with_secured_envd(async_sandbox): + await async_sandbox.files.watch_dir("/home/user/", on_event=lambda e: None) + await async_sandbox.files.write("test_watch.txt", "This file will be watched.") diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_write.py b/packages/python-sdk/tests/async/sandbox_async/files/test_write.py new file mode 100644 index 0000000..dd06513 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_write.py @@ -0,0 +1,220 @@ +import io +import uuid + +from e2b import AsyncSandbox +from e2b.sandbox.filesystem.filesystem import FileType, WriteEntry +from e2b.sandbox_async.filesystem.filesystem import WriteInfo + + +async def test_write_text_file(async_sandbox: AsyncSandbox, debug): + filename = "test_write.txt" + content = "This is a test file." + + info = await async_sandbox.files.write(filename, content) + assert info.path == f"/home/user/{filename}" + assert info.type == FileType.FILE + + exists = await async_sandbox.files.exists(filename) + assert exists + + read_content = await async_sandbox.files.read(filename) + assert read_content == content + + if debug: + await async_sandbox.files.remove(filename) + + +async def test_write_binary_file(async_sandbox: AsyncSandbox, debug): + filename = "test_write.bin" + text = "This is a test binary file." + # equivalent to `open("path/to/local/file", "rb")` + content = io.BytesIO(text.encode("utf-8")) + + info = await async_sandbox.files.write(filename, content) + assert info.path == f"/home/user/{filename}" + + exists = await async_sandbox.files.exists(filename) + assert exists + + read_content = await async_sandbox.files.read(filename) + assert read_content == text + + if debug: + await async_sandbox.files.remove(filename) + + +async def test_write_multiple_files(async_sandbox: AsyncSandbox, debug): + num_test_files = 10 + + # Attempt to write with empty files array + empty_info = await async_sandbox.files.write_files([]) + assert isinstance(empty_info, list) + assert len(empty_info) == 0 + + # Attempt to write with one file in array + one_file_path = "one_test_file.txt" + info = await async_sandbox.files.write_files( + [WriteEntry(path=one_file_path, data="This is a test file.")] + ) + + assert isinstance(info, list) + assert len(info) == 1 + info = info[0] + assert isinstance(info, WriteInfo) + assert info.path == "/home/user/one_test_file.txt" + exists = await async_sandbox.files.exists(info.path) + assert exists + + read_content = await async_sandbox.files.read(info.path) + assert read_content == "This is a test file." + + # Attempt to write with multiple files in array + files = [] + for i in range(num_test_files): + path = f"test_write_{i}.txt" + content = f"This is a test file {i}." + files.append(WriteEntry(path=path, data=content)) + + infos = await async_sandbox.files.write_files(files) + assert isinstance(infos, list) + assert len(infos) == len(files) + for i, info in enumerate(infos): + assert isinstance(info, WriteInfo) + assert info.path == f"/home/user/test_write_{i}.txt" + assert info.type == FileType.FILE + exists = await async_sandbox.files.exists(path) + assert exists + + read_content = await async_sandbox.files.read(info.path) + assert read_content == files[i]["data"] + + if debug: + await async_sandbox.files.remove(one_file_path) + for i in range(num_test_files): + await async_sandbox.files.remove(f"test_write_{i}.txt") + + +async def test_overwrite_file(async_sandbox: AsyncSandbox, debug): + filename = "test_overwrite.txt" + initial_content = "Initial content." + new_content = "New content." + + await async_sandbox.files.write(filename, initial_content) + await async_sandbox.files.write(filename, new_content) + read_content = await async_sandbox.files.read(filename) + assert read_content == new_content + + if debug: + await async_sandbox.files.remove(filename) + + +async def test_write_to_non_existing_directory(async_sandbox: AsyncSandbox, debug): + filename = f"non_existing_dir_{uuid.uuid4()}/test_write.txt" + content = "This should succeed too." + + await async_sandbox.files.write(filename, content) + exists = await async_sandbox.files.exists(filename) + assert exists + + read_content = await async_sandbox.files.read(filename) + assert read_content == content + + if debug: + await async_sandbox.files.remove(filename) + + +async def test_write_with_secured_envd(async_sandbox_factory): + filename = f"non_existing_dir_{uuid.uuid4()}/test_write.txt" + content = "This should succeed too." + + sbx = await async_sandbox_factory(timeout=30, secure=True) + + assert await sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + + await sbx.files.write(filename, content) + + exists = await sbx.files.exists(filename) + assert exists + + read_content = await sbx.files.read(filename) + assert read_content == content + + +async def test_write_files_with_different_data_types( + async_sandbox: AsyncSandbox, debug +): + text_data = "Text string data" + bytes_data = b"Bytes data" + bytes_io_data = io.BytesIO(b"BytesIO data") + string_io_data = io.StringIO("StringIO data") + + files = [ + WriteEntry(path="writefiles_text.txt", data=text_data), + WriteEntry(path="writefiles_bytes.bin", data=bytes_data), + WriteEntry(path="writefiles_bytesio.bin", data=bytes_io_data), + WriteEntry(path="writefiles_stringio.txt", data=string_io_data), + ] + + infos = await async_sandbox.files.write_files(files) + + assert len(infos) == 4 + + text_content = await async_sandbox.files.read("writefiles_text.txt") + assert text_content == text_data + + bytes_content = await async_sandbox.files.read("writefiles_bytes.bin") + assert bytes_content == "Bytes data" + + bytes_io_content = await async_sandbox.files.read("writefiles_bytesio.bin") + assert bytes_io_content == "BytesIO data" + + string_io_content = await async_sandbox.files.read("writefiles_stringio.txt") + assert string_io_content == "StringIO data" + + if debug: + for file in files: + await async_sandbox.files.remove(file["path"]) + + +async def test_write_io_with_octet_stream(async_sandbox: AsyncSandbox, debug): + filename = "test_write_octet_io.bin" + text = "Streamed octet-stream upload. " * 10_000 + content = io.BytesIO(text.encode("utf-8")) + + info = await async_sandbox.files.write(filename, content, use_octet_stream=True) + assert info.path == f"/home/user/{filename}" + + read_content = await async_sandbox.files.read(filename) + assert read_content == text + + if debug: + await async_sandbox.files.remove(filename) + + +async def test_write_text_io_with_octet_stream(async_sandbox: AsyncSandbox, debug): + filename = "test_write_octet_text_io.txt" + text = "Streamed text octet-stream upload." + + await async_sandbox.files.write(filename, io.StringIO(text), use_octet_stream=True) + + read_content = await async_sandbox.files.read(filename) + assert read_content == text + + if debug: + await async_sandbox.files.remove(filename) + + +async def test_write_io_with_octet_stream_and_gzip(async_sandbox: AsyncSandbox, debug): + filename = "test_write_octet_io_gzip.bin" + text = "Streamed gzipped octet-stream upload. " * 10_000 + content = io.BytesIO(text.encode("utf-8")) + + await async_sandbox.files.write(filename, content, use_octet_stream=True, gzip=True) + + read_content = await async_sandbox.files.read(filename) + assert read_content == text + + if debug: + await async_sandbox.files.remove(filename) diff --git a/packages/python-sdk/tests/async/sandbox_async/pty/test_pty_connect.py b/packages/python-sdk/tests/async/sandbox_async/pty/test_pty_connect.py new file mode 100644 index 0000000..166ebe4 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/pty/test_pty_connect.py @@ -0,0 +1,41 @@ +import asyncio + +from e2b import AsyncSandbox +from e2b.sandbox.commands.command_handle import PtySize + + +async def test_connect_to_pty(async_sandbox: AsyncSandbox): + output1 = [] + output2 = [] + + def append_data(data: list, x: bytes): + data.append(x.decode("utf-8")) + + # First, create a terminal and disconnect the on_data handler + terminal = await async_sandbox.pty.create( + PtySize(80, 24), + on_data=lambda x: append_data(output1, x), + envs={"FOO": "bar"}, + ) + + await async_sandbox.pty.send_stdin(terminal.pid, b"echo $FOO\n") + + # Give time for the command output in the first connection + await asyncio.sleep(0.3) + + await terminal.disconnect() + + # Now connect again, with a new on_data handler + reconnect_handle = await async_sandbox.pty.connect( + terminal.pid, on_data=lambda x: append_data(output2, x) + ) + + await async_sandbox.pty.send_stdin(terminal.pid, b"echo $FOO\nexit\n") + + await reconnect_handle.wait() + + assert terminal.pid == reconnect_handle.pid + assert reconnect_handle.exit_code == 0 + + assert "bar" in "".join(output1) + assert "bar" in "".join(output2) diff --git a/packages/python-sdk/tests/async/sandbox_async/pty/test_pty_create.py b/packages/python-sdk/tests/async/sandbox_async/pty/test_pty_create.py new file mode 100644 index 0000000..eec044d --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/pty/test_pty_create.py @@ -0,0 +1,21 @@ +from e2b import AsyncSandbox +from e2b.sandbox.commands.command_handle import PtySize + + +async def test_pty_create(async_sandbox: AsyncSandbox): + output = [] + + def append_data(data: list, x: bytes): + data.append(x.decode("utf-8")) + + terminal = await async_sandbox.pty.create( + PtySize(80, 24), on_data=lambda x: append_data(output, x), envs={"ABC": "123"} + ) + + await async_sandbox.pty.send_stdin(terminal.pid, b"echo $ABC\n") + await async_sandbox.pty.send_stdin(terminal.pid, b"exit\n") + + await terminal.wait() + assert terminal.exit_code == 0 + + assert "123" in "".join(output) diff --git a/packages/python-sdk/tests/async/sandbox_async/pty/test_pty_kill.py b/packages/python-sdk/tests/async/sandbox_async/pty/test_pty_kill.py new file mode 100644 index 0000000..2547270 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/pty/test_pty_kill.py @@ -0,0 +1,20 @@ +import pytest + +from e2b import AsyncSandbox, CommandExitException +from e2b.sandbox.commands.command_handle import PtySize + + +async def test_kill_pty(async_sandbox: AsyncSandbox): + terminal = await async_sandbox.pty.create(PtySize(80, 24), on_data=lambda _: None) + + assert await async_sandbox.pty.kill(terminal.pid) + + # The PTY process should no longer be running. + with pytest.raises(CommandExitException): + await async_sandbox.commands.run(f"kill -0 {terminal.pid}") + + +async def test_kill_non_existing_pty(async_sandbox: AsyncSandbox): + non_existing_pid = 999999 + + assert not await async_sandbox.pty.kill(non_existing_pid) diff --git a/packages/python-sdk/tests/async/sandbox_async/pty/test_resize.py b/packages/python-sdk/tests/async/sandbox_async/pty/test_resize.py new file mode 100644 index 0000000..ca91ff6 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/pty/test_resize.py @@ -0,0 +1,34 @@ +from e2b import AsyncSandbox +from e2b.sandbox.commands.command_handle import PtySize + + +async def test_resize(async_sandbox: AsyncSandbox): + output = [] + + def append_data(data: list, x: bytes): + data.append(x.decode("utf-8")) + + terminal = await async_sandbox.pty.create( + PtySize(cols=80, rows=24), on_data=lambda x: append_data(output, x) + ) + + await async_sandbox.pty.send_stdin(terminal.pid, b"tput cols\n") + await async_sandbox.pty.send_stdin(terminal.pid, b"exit\n") + await terminal.wait() + assert terminal.exit_code == 0 + + assert "80" in "".join(output) + + output = [] + + terminal = await async_sandbox.pty.create( + PtySize(cols=80, rows=24), on_data=lambda x: append_data(output, x) + ) + + await async_sandbox.pty.resize(terminal.pid, PtySize(cols=100, rows=24)) + await async_sandbox.pty.send_stdin(terminal.pid, b"tput cols\n") + await async_sandbox.pty.send_stdin(terminal.pid, b"exit\n") + + await terminal.wait() + assert terminal.exit_code == 0 + assert "100" in "".join(output) diff --git a/packages/python-sdk/tests/async/sandbox_async/pty/test_send_input.py b/packages/python-sdk/tests/async/sandbox_async/pty/test_send_input.py new file mode 100644 index 0000000..d54eeaa --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/pty/test_send_input.py @@ -0,0 +1,11 @@ +from e2b import AsyncSandbox +from e2b.sandbox.commands.command_handle import PtySize + + +async def test_send_input(async_sandbox: AsyncSandbox): + terminal = await async_sandbox.pty.create( + PtySize(cols=80, rows=24), on_data=lambda x: print(x) + ) + await async_sandbox.pty.send_stdin(terminal.pid, b"exit\n") + await terminal.wait() + assert terminal.exit_code == 0 diff --git a/packages/python-sdk/tests/async/sandbox_async/test_config_propagation.py b/packages/python-sdk/tests/async/sandbox_async/test_config_propagation.py new file mode 100644 index 0000000..1acfe87 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_config_propagation.py @@ -0,0 +1,128 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from packaging.version import Version + +from e2b import AsyncSandbox +from e2b.api import SandboxCreateResponse +from e2b.connection_config import ConnectionConfig +import e2b.sandbox_async.main as sandbox_async_main + +BASE_DOMAIN = "base.e2b.dev" +BASE_REQUEST_TIMEOUT = 11 +BASE_DEBUG = False +BASE_HEADERS = {"X-Test": "base"} + + +def create_sandbox(monkeypatch, api_key: str) -> AsyncSandbox: + dummy_transport = SimpleNamespace(pool=object()) + + monkeypatch.setattr( + sandbox_async_main, "get_transport", lambda *_args, **_kwargs: dummy_transport + ) + monkeypatch.setattr( + sandbox_async_main.httpx, "AsyncClient", lambda *args, **kwargs: object() + ) + monkeypatch.setattr( + sandbox_async_main, "Filesystem", lambda *args, **kwargs: object() + ) + monkeypatch.setattr( + sandbox_async_main, "Commands", lambda *args, **kwargs: object() + ) + monkeypatch.setattr(sandbox_async_main, "Pty", lambda *args, **kwargs: object()) + monkeypatch.setattr(sandbox_async_main, "Git", lambda *args, **kwargs: object()) + + return AsyncSandbox( + sandbox_id="sbx-test", + sandbox_domain="sandbox.e2b.dev", + envd_version=Version("0.2.4"), + envd_access_token="tok", + traffic_access_token="tok", + connection_config=ConnectionConfig( + api_key=api_key, + domain=BASE_DOMAIN, + request_timeout=BASE_REQUEST_TIMEOUT, + debug=BASE_DEBUG, + api_headers=BASE_HEADERS, + ), + ) + + +@pytest.mark.skip_debug() +async def test_pause_passes_connection_config_without_overrides( + monkeypatch, test_api_key +): + mock_pause = AsyncMock(return_value="sbx-test") + monkeypatch.setattr(sandbox_async_main.SandboxApi, "_cls_pause", mock_pause) + + sandbox = create_sandbox(monkeypatch, test_api_key) + await sandbox.pause() + + mock_pause.assert_awaited_once() + assert mock_pause.call_args.kwargs["sandbox_id"] == "sbx-test" + assert mock_pause.call_args.kwargs["api_key"] == test_api_key + assert mock_pause.call_args.kwargs["domain"] == BASE_DOMAIN + assert mock_pause.call_args.kwargs["request_timeout"] == BASE_REQUEST_TIMEOUT + assert mock_pause.call_args.kwargs["debug"] == BASE_DEBUG + assert mock_pause.call_args.kwargs["headers"]["X-Test"] == BASE_HEADERS["X-Test"] + + +@pytest.mark.skip_debug() +async def test_pause_applies_overrides(monkeypatch, test_api_key): + mock_pause = AsyncMock(return_value="sbx-test") + monkeypatch.setattr(sandbox_async_main.SandboxApi, "_cls_pause", mock_pause) + + sandbox = create_sandbox(monkeypatch, test_api_key) + await sandbox.pause( + domain="override.e2b.dev", + request_timeout=20, + api_headers={"X-Extra": "1"}, + ) + + mock_pause.assert_awaited_once() + assert mock_pause.call_args.kwargs["sandbox_id"] == "sbx-test" + assert mock_pause.call_args.kwargs["api_key"] == test_api_key + assert mock_pause.call_args.kwargs["domain"] == "override.e2b.dev" + assert mock_pause.call_args.kwargs["request_timeout"] == 20 + assert mock_pause.call_args.kwargs["debug"] == BASE_DEBUG + assert mock_pause.call_args.kwargs["headers"]["X-Test"] == BASE_HEADERS["X-Test"] + assert mock_pause.call_args.kwargs["headers"]["X-Extra"] == "1" + + +@pytest.mark.skip_debug() +async def test_connect_sets_stable_host_routing_headers(monkeypatch, test_api_key): + mock_connect = AsyncMock( + return_value=SandboxCreateResponse( + sandbox_id="sbx-test", + sandbox_domain="e2b.app", + envd_version="0.4.0", + envd_access_token="tok", + traffic_access_token="traffic", + ) + ) + monkeypatch.setattr(sandbox_async_main.SandboxApi, "_cls_connect", mock_connect) + + monkeypatch.setattr(ConnectionConfig, "_integration", "testing/version") + config = ConnectionConfig( + api_key=test_api_key, + headers=BASE_HEADERS, + ) + sandbox = await AsyncSandbox.connect( + "sbx-test", + **config.get_api_params(), + ) + + assert sandbox.envd_api_url == "https://sandbox.e2b.app" + assert "X-Test" not in sandbox.connection_config.sandbox_headers + assert sandbox.connection_config.sandbox_headers["User-Agent"].startswith( + "e2b-python-sdk/" + ) + assert sandbox.connection_config.sandbox_headers["User-Agent"].endswith( + " testing/version" + ) + assert sandbox.connection_config.sandbox_headers["E2b-Sandbox-Id"] == "sbx-test" + assert sandbox.connection_config.sandbox_headers["E2b-Sandbox-Port"] == str( + ConnectionConfig.envd_port + ) + assert sandbox.connection_config.sandbox_headers["X-Access-Token"] == "tok" diff --git a/packages/python-sdk/tests/async/sandbox_async/test_connect.py b/packages/python-sdk/tests/async/sandbox_async/test_connect.py new file mode 100644 index 0000000..51ec2b6 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_connect.py @@ -0,0 +1,145 @@ +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from e2b import AsyncSandbox +from e2b.api.client.api.sandboxes import post_sandboxes_sandbox_id_connect +from e2b.api.client.models import Sandbox as SandboxModel +import e2b.sandbox_async.main as sandbox_async_main + + +@pytest.mark.skip_debug() +async def test_connect(async_sandbox_factory): + sbx = await async_sandbox_factory(timeout=10) + + assert await sbx.is_running() + + sbx_connection = await AsyncSandbox.connect(sbx.sandbox_id) + assert await sbx_connection.is_running() + + +@pytest.mark.skip_debug() +async def test_connect_with_secure(async_sandbox_factory): + dir_name = f"test_directory_{uuid.uuid4()}" + + sbx = await async_sandbox_factory(timeout=10, secure=True) + assert await sbx.is_running() + + sbx_connection = await AsyncSandbox.connect(sbx.sandbox_id) + + await sbx_connection.files.make_dir(dir_name) + files = await sbx_connection.files.list(dir_name) + assert len(files) == 0 + assert await sbx_connection.is_running() + + +@pytest.mark.skip_debug() +async def test_connect_to_paused_sandbox_resumes(async_sandbox): + await async_sandbox.pause() + assert not await async_sandbox.is_running() + + resumed = await AsyncSandbox.connect(async_sandbox.sandbox_id) + assert await resumed.is_running() + + +@pytest.mark.skip_debug() +async def test_resume_does_not_shorten_timeout_on_running_sandbox( + async_sandbox_factory, +): + # Create sandbox with a 300 second timeout + sbx = await async_sandbox_factory(timeout=300) + assert await sbx.is_running() + + # Get initial info to check end_at + info_before = await AsyncSandbox.get_info(sbx.sandbox_id) + + # Connect with a shorter timeout (10 seconds) + await AsyncSandbox.connect(sbx.sandbox_id, timeout=10) + + # Get info after connection + info_after = await AsyncSandbox.get_info(sbx.sandbox_id) + + # The end_at time should not have been shortened. It should be the same + assert info_after.end_at == info_before.end_at, ( + f"Timeout was changed: before={info_before.end_at}, after={info_after.end_at}" + ) + + +@pytest.mark.skip_debug() +async def test_connect_extends_timeout_on_running_sandbox(async_sandbox): + # Create sandbox with a short timeout + assert await async_sandbox.is_running() + + # Get initial info to check end_at + info_before = await async_sandbox.get_info() + + # Connect with a longer timeout + await AsyncSandbox.connect(async_sandbox.sandbox_id, timeout=600) + + # Get info after connection + info_after = await AsyncSandbox.get_info(async_sandbox.sandbox_id) + + # The end_at time should have been extended + assert info_after.end_at > info_before.end_at, ( + f"Timeout was not extended: before={info_before.end_at}, after={info_after.end_at}" + ) + + +async def test_connect_in_debug_mode_does_not_call_api(monkeypatch, test_api_key): + mock_connect = AsyncMock() + monkeypatch.setattr(sandbox_async_main.SandboxApi, "_cls_connect", mock_connect) + + sbx = await AsyncSandbox.connect("sbx-debug", debug=True, api_key=test_api_key) + + mock_connect.assert_not_called() + assert sbx.sandbox_id == "sbx-debug" + assert sbx._envd_access_token is None + assert sbx.traffic_access_token is None + + +async def test_connect_in_env_debug_mode_does_not_call_api(monkeypatch, test_api_key): + monkeypatch.setenv("E2B_DEBUG", "true") + mock_connect = AsyncMock() + monkeypatch.setattr(sandbox_async_main.SandboxApi, "_cls_connect", mock_connect) + + sbx = await AsyncSandbox.connect("sbx-debug", api_key=test_api_key) + + mock_connect.assert_not_called() + assert sbx.sandbox_id == "sbx-debug" + + +async def test_instance_connect_in_debug_mode_does_not_call_api( + monkeypatch, test_api_key +): + mock_connect = AsyncMock() + monkeypatch.setattr(sandbox_async_main.SandboxApi, "_cls_connect", mock_connect) + + sbx = await AsyncSandbox.connect("sbx-debug", debug=True, api_key=test_api_key) + + assert await sbx.connect() is sbx + mock_connect.assert_not_called() + + +async def test_connect_normalizes_unset_tokens(monkeypatch, test_api_key): + # Tokens and domain are absent in the API response for non-secure sandboxes + model = SandboxModel( + client_id="client-id", + envd_version="0.2.4", + sandbox_id="sbx-test", + template_id="template-id", + ) + mock_request = AsyncMock( + return_value=SimpleNamespace(status_code=200, parsed=model) + ) + monkeypatch.setattr( + post_sandboxes_sandbox_id_connect, "asyncio_detailed", mock_request + ) + + sbx = await AsyncSandbox.connect("sbx-test", debug=False, api_key=test_api_key) + + mock_request.assert_called_once() + assert sbx._envd_access_token is None + assert sbx.traffic_access_token is None + assert "signature" not in sbx.download_url("test.txt") diff --git a/packages/python-sdk/tests/async/sandbox_async/test_create.py b/packages/python-sdk/tests/async/sandbox_async/test_create.py new file mode 100644 index 0000000..aa17c2c --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_create.py @@ -0,0 +1,194 @@ +import asyncio +from typing import Any, cast + +import httpx +import pytest + +from e2b import AsyncSandbox, SandboxQuery, SandboxState +from e2b.api.client.models import ( + NewSandbox, + SandboxAutoResumeConfig, +) +from e2b.exceptions import InvalidArgumentException + + +@pytest.mark.skip_debug() +async def test_start(async_sandbox): + assert await async_sandbox.is_running() + assert async_sandbox._envd_version is not None + + +@pytest.mark.skip_debug() +async def test_metadata(async_sandbox_factory): + sbx = await async_sandbox_factory(timeout=5, metadata={"test-key": "test-value"}) + + paginator = AsyncSandbox.list( + query=SandboxQuery(metadata={"test-key": "test-value"}) + ) + sandboxes = await paginator.next_items() + + for sbx_info in sandboxes: + if sbx.sandbox_id == sbx_info.sandbox_id: + assert sbx_info.metadata is not None + assert sbx_info.metadata["test-key"] == "test-value" + break + else: + assert False, "Sandbox not found" + + +def test_create_payload_serializes_auto_resume_enabled(): + body = NewSandbox( + template_id="template-id", + auto_pause=True, + auto_resume=SandboxAutoResumeConfig(enabled=True), + ) + + assert body.to_dict()["autoPause"] is True + assert body.to_dict()["autoResume"] == {"enabled": True} + + +def test_create_payload_deserializes_auto_resume_enabled(): + body = NewSandbox.from_dict( + { + "templateID": "template-id", + "autoPause": False, + "autoResume": {"enabled": False}, + } + ) + + assert isinstance(body.auto_resume, SandboxAutoResumeConfig) + assert body.auto_resume.to_dict() == {"enabled": False} + + +@pytest.mark.skip_debug() +async def test_filesystem_only_auto_pause_rejects_auto_resume(): + # A filesystem-only auto-pause snapshot can only be resumed explicitly, so + # combining keep_memory=False with auto_resume is rejected client-side. + with pytest.raises(InvalidArgumentException): + await AsyncSandbox.create( + timeout=3, + lifecycle={ + "on_timeout": {"action": "pause", "keep_memory": False}, + "auto_resume": True, + }, + ) + + +@pytest.mark.skip_debug() +async def test_keep_memory_not_allowed_with_kill(): + # The discriminated union forbids keep_memory on action="kill" at type-check + # time; the runtime guard rejects it for callers that bypass the type + # (cast(Any, ...) feeds the deliberately type-invalid input). + with pytest.raises(InvalidArgumentException): + await AsyncSandbox.create( + timeout=3, + lifecycle=cast( + Any, {"on_timeout": {"action": "kill", "keep_memory": False}} + ), + ) + + +@pytest.mark.skip_debug() +async def test_invalid_on_timeout_type_does_not_crash(async_sandbox_factory): + # An untyped/invalid on_timeout (e.g. None) must not crash create; it falls + # back to kill semantics like a missing on_timeout (the sandbox just starts). + sbx = await async_sandbox_factory( + timeout=10, lifecycle=cast(Any, {"on_timeout": None}) + ) + assert await sbx.is_running() + + +@pytest.mark.skip_debug() +async def test_keep_memory_none_defaults_to_full_memory(async_sandbox_factory): + # An explicit None keep_memory must default to full memory (not filesystem-only): + # the timeout auto-pause then resumes the SAME sandbox in place (memory restore), + # so the boot id is unchanged. A changed boot id would mean None was wrongly + # treated as filesystem-only (cold boot). + sbx = await async_sandbox_factory( + timeout=60, + lifecycle={"on_timeout": {"action": "pause", "keep_memory": None}}, + ) + boot_before = (await sbx.files.read("/proc/sys/kernel/random/boot_id")).strip() + + await sbx.set_timeout(0) # force the timeout auto-pause now + for _ in range(150): + if not await sbx.is_running(): + break + await asyncio.sleep(0.2) + assert not await sbx.is_running() + + resumed = await sbx.connect() + assert resumed.sandbox_id == sbx.sandbox_id # same sandbox + boot_after = (await resumed.files.read("/proc/sys/kernel/random/boot_id")).strip() + assert boot_after == boot_before # memory restore in place, not a cold boot + + +@pytest.mark.skip_debug() +async def test_auto_pause_filesystem_only_reboots(async_sandbox_factory): + # keep_memory=False makes the timeout auto-pause filesystem-only, so resuming + # cold-boots the sandbox from disk. + sandbox = await async_sandbox_factory( + timeout=3, + lifecycle={"on_timeout": {"action": "pause", "keep_memory": False}}, + ) + + marker = "auto-pause-fs-only" + await sandbox.files.write("/home/user/auto-pause-marker.txt", marker) + boot_before = (await sandbox.files.read("/proc/sys/kernel/random/boot_id")).strip() + + await asyncio.sleep(5) + + assert (await sandbox.get_info()).state == SandboxState.PAUSED + + # A filesystem-only snapshot cannot auto-resume on traffic; connect resumes + # it by cold-booting. + resumed = await sandbox.connect() + + persisted = (await resumed.files.read("/home/user/auto-pause-marker.txt")).strip() + assert persisted == marker + + boot_after = (await resumed.files.read("/proc/sys/kernel/random/boot_id")).strip() + assert boot_after != boot_before + + +@pytest.mark.skip_debug() +async def test_auto_pause_without_auto_resume_requires_connect(async_sandbox_factory): + sandbox = await async_sandbox_factory( + timeout=3, + lifecycle={"on_timeout": "pause", "auto_resume": False}, + ) + + await asyncio.sleep(5) + + assert (await sandbox.get_info()).state == SandboxState.PAUSED + assert not await sandbox.is_running() + + await sandbox.connect() + + assert (await sandbox.get_info()).state == SandboxState.RUNNING + assert await sandbox.is_running() + + +@pytest.mark.skip_debug() +async def test_auto_resume_wakes_on_http_request(async_sandbox_factory): + sandbox = await async_sandbox_factory( + timeout=3, + lifecycle={"on_timeout": "pause", "auto_resume": True}, + ) + + cmd = await sandbox.commands.run("python3 -m http.server 8000", background=True) + try: + await asyncio.sleep(5) + + url = f"https://{sandbox.get_host(8000)}" + async with httpx.AsyncClient(timeout=15.0) as client: + res = await client.get(url) + + assert res.status_code == 200 + assert (await sandbox.get_info()).state == SandboxState.RUNNING + assert await sandbox.is_running() + finally: + try: + await cmd.kill() + except Exception: + pass diff --git a/packages/python-sdk/tests/async/sandbox_async/test_host.py b/packages/python-sdk/tests/async/sandbox_async/test_host.py new file mode 100644 index 0000000..9b3c90d --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_host.py @@ -0,0 +1,30 @@ +import asyncio + +import httpx + +from e2b import AsyncSandbox + + +async def test_ping_server(async_sandbox: AsyncSandbox, debug, helpers): + cmd = await async_sandbox.commands.run( + "python -m http.server 8000", + background=True, + ) + + disable = helpers.catch_cmd_exit_error_in_background(cmd) + + try: + host = async_sandbox.get_host(8000) + + status_code = None + async with httpx.AsyncClient() as client: + for _ in range(20): + res = await client.get(f"{'http' if debug else 'https'}://{host}") + status_code = res.status_code + if res.status_code == 200: + break + await asyncio.sleep(0.5) + assert status_code == 200 + disable() + finally: + await cmd.kill() diff --git a/packages/python-sdk/tests/async/sandbox_async/test_internet_access.py b/packages/python-sdk/tests/async/sandbox_async/test_internet_access.py new file mode 100644 index 0000000..4eb6e2f --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_internet_access.py @@ -0,0 +1,42 @@ +import pytest + +from e2b.sandbox.commands.command_handle import CommandExitException + + +@pytest.mark.skip_debug() +async def test_internet_access_enabled(async_sandbox_factory): + """Test that sandbox with internet access enabled can reach external websites.""" + sbx = await async_sandbox_factory(allow_internet_access=True) + + # Test internet connectivity by making a curl request to a reliable external site + result = await sbx.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://connectivitycheck.gstatic.com/generate_204" + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "204" + + +@pytest.mark.skip_debug() +async def test_internet_access_disabled(async_sandbox_factory): + """Test that sandbox with internet access disabled cannot reach external websites.""" + sbx = await async_sandbox_factory(allow_internet_access=False) + + # Test that internet connectivity is blocked by making a curl request + with pytest.raises(CommandExitException) as exc_info: + await sbx.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://connectivitycheck.gstatic.com/generate_204" + ) + # The command should fail or timeout when internet access is disabled + assert exc_info.value.exit_code != 0 + + +@pytest.mark.skip_debug() +async def test_internet_access_default(async_sandbox): + """Test that sandbox with default settings (no explicit allow_internet_access) has internet access.""" + # Test internet connectivity by making a curl request to a reliable external site + + result = await async_sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://connectivitycheck.gstatic.com/generate_204" + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "204" diff --git a/packages/python-sdk/tests/async/sandbox_async/test_kill.py b/packages/python-sdk/tests/async/sandbox_async/test_kill.py new file mode 100644 index 0000000..f57502b --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_kill.py @@ -0,0 +1,16 @@ +import pytest + +from e2b import AsyncSandbox, SandboxQuery, SandboxState + + +@pytest.mark.skip_debug() +async def test_kill(async_sandbox: AsyncSandbox, sandbox_test_id: str): + await async_sandbox.kill() + + paginator = AsyncSandbox.list( + query=SandboxQuery( + state=[SandboxState.RUNNING], metadata={"sandbox_test_id": sandbox_test_id} + ) + ) + sandboxes = await paginator.next_items() + assert async_sandbox.sandbox_id not in [s.sandbox_id for s in sandboxes] diff --git a/packages/python-sdk/tests/async/sandbox_async/test_metrics.py b/packages/python-sdk/tests/async/sandbox_async/test_metrics.py new file mode 100644 index 0000000..d065fa8 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_metrics.py @@ -0,0 +1,62 @@ +import asyncio +import datetime + +import pytest + + +@pytest.mark.skip_debug() +@pytest.mark.timeout(60) +async def test_sbx_metrics(async_sandbox_factory): + sbx = await async_sandbox_factory(timeout=60) + + # Wait for the sandbox to have some metrics + metrics = [] + for _ in range(60): + metrics = await sbx.get_metrics() + if len(metrics) > 0: + break + await asyncio.sleep(0.5) + + assert len(metrics) > 0 + + metric = metrics[0] + assert metric.cpu_count is not None + assert metric.cpu_used_pct is not None + assert metric.mem_used is not None + assert metric.mem_total is not None + assert metric.disk_used is not None + assert metric.disk_total is not None + + +@pytest.mark.skip_debug() +@pytest.mark.timeout(60) +async def test_sbx_metrics_time_range(async_sandbox_factory): + start_time = datetime.datetime.now(datetime.timezone.utc) + sbx = await async_sandbox_factory(timeout=60) + + # Wait for the sandbox to have some metrics within the test's time window + metrics = [] + end_time = start_time + for _ in range(60): + end_time = datetime.datetime.now(datetime.timezone.utc) + metrics = await sbx.get_metrics(start=start_time, end=end_time) + if len(metrics) > 0: + break + await asyncio.sleep(0.5) + + assert len(metrics) > 0 + + # All returned metrics must fall within the requested time range + # (10s slack - metric timestamps are aligned to collection buckets, + # currently 5s, and the query params are second-precision) + slack = 10 + for metric in metrics: + assert metric.timestamp.timestamp() >= start_time.timestamp() - slack + assert metric.timestamp.timestamp() <= end_time.timestamp() + slack + + # A time range from before the sandbox existed must return no metrics + metrics = await sbx.get_metrics( + start=start_time - datetime.timedelta(hours=1), + end=start_time - datetime.timedelta(minutes=30), + ) + assert len(metrics) == 0 diff --git a/packages/python-sdk/tests/async/sandbox_async/test_network.py b/packages/python-sdk/tests/async/sandbox_async/test_network.py new file mode 100644 index 0000000..9e8aa50 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_network.py @@ -0,0 +1,318 @@ +import asyncio +import json + +import httpx +import pytest + +from e2b import SandboxNetworkOpts +from e2b.sandbox.commands.command_handle import CommandExitException + + +async def wait_for_status( + client: httpx.AsyncClient, + url: str, + status_code: int, + headers: dict[str, str] | None = None, + timeout: float = 15, +) -> httpx.Response: + deadline = asyncio.get_running_loop().time() + timeout + response: httpx.Response | None = None + + while asyncio.get_running_loop().time() < deadline: + response = await client.get(url, headers=headers, follow_redirects=True) + if response.status_code == status_code: + return response + await asyncio.sleep(1) + + assert response is not None + return response + + +@pytest.mark.skip_debug() +async def test_allow_specific_ip_with_deny_all(async_sandbox_factory): + """Test that sandbox with denyOut all and allowOut creates a whitelist.""" + async_sandbox = await async_sandbox_factory( + network=SandboxNetworkOpts( + deny_out=lambda ctx: [ctx.all_traffic], allow_out=["1.1.1.1"] + ) + ) + + # Test that allowed IP works + result = await async_sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "301" + + # Test that other IPs are denied + with pytest.raises(CommandExitException) as exc_info: + await async_sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8" + ) + assert exc_info.value.exit_code != 0 + + +@pytest.mark.skip_debug() +async def test_deny_specific_ip(async_sandbox_factory): + """Test that sandbox with denyOut denies specified IP addresses.""" + async_sandbox = await async_sandbox_factory( + network=SandboxNetworkOpts(deny_out=["8.8.8.8"]) + ) + + # Test that denied IP fails + with pytest.raises(CommandExitException) as exc_info: + await async_sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8" + ) + assert exc_info.value.exit_code != 0 + + # Test that other IPs work + result = await async_sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "301" + + +@pytest.mark.skip_debug() +async def test_deny_all_traffic(async_sandbox_factory): + """Test that sandbox can deny all traffic using the all_traffic selector.""" + async_sandbox = await async_sandbox_factory( + network=SandboxNetworkOpts(deny_out=lambda ctx: [ctx.all_traffic]), timeout=30 + ) + + # Test that all traffic is denied + with pytest.raises(CommandExitException) as exc_info: + await async_sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://1.1.1.1" + ) + assert exc_info.value.exit_code != 0 + + with pytest.raises(CommandExitException) as exc_info: + await async_sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8" + ) + assert exc_info.value.exit_code != 0 + + +@pytest.mark.skip_debug() +async def test_allow_takes_precedence_over_deny(async_sandbox_factory): + """Test that allowOut takes precedence over denyOut.""" + async_sandbox = await async_sandbox_factory( + network=SandboxNetworkOpts( + deny_out=lambda ctx: [ctx.all_traffic], allow_out=["1.1.1.1", "8.8.8.8"] + ) + ) + + # Test that 1.1.1.1 works (explicitly allowed) + result1 = await async_sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert result1.exit_code == 0 + assert result1.stdout.strip() == "301" + + # Test that 8.8.8.8 also works (explicitly allowed, takes precedence over deny_out) + result2 = await async_sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8" + ) + assert result2.exit_code == 0 + assert result2.stdout.strip() == "302" + + +@pytest.mark.skip_debug() +async def test_allow_public_traffic_false(async_sandbox_factory): + """Test that sandbox with allow_public_traffic=False requires traffic access token.""" + async_sandbox = await async_sandbox_factory( + secure=True, network=SandboxNetworkOpts(allow_public_traffic=False) + ) + + # Verify the sandbox was created successfully and has a traffic access token + assert async_sandbox.traffic_access_token is not None + + # Start a simple HTTP server in the sandbox + port = 8080 + await async_sandbox.commands.run( + f"python3 -m http.server {port}", background=True, timeout=0 + ) + + # Wait for server to start + await asyncio.sleep(3) + + # Get the public URL for the sandbox + sandbox_url = f"https://{async_sandbox.get_host(port)}" + + async with httpx.AsyncClient() as client: + # Test 1: Request without traffic access token should fail with 403 + response = await client.get(sandbox_url, follow_redirects=True) + assert response.status_code == 403 + + # Test 2: Request with valid traffic access token should succeed + headers = {"e2b-traffic-access-token": async_sandbox.traffic_access_token} + response = await wait_for_status(client, sandbox_url, 200, headers=headers) + assert response.status_code == 200 + + +@pytest.mark.skip_debug() +async def test_allow_public_traffic_true(async_sandbox_factory): + """Test that sandbox with allow_public_traffic=True works without token.""" + async_sandbox = await async_sandbox_factory( + network=SandboxNetworkOpts(allow_public_traffic=True) + ) + + # Start a simple HTTP server in the sandbox + port = 8080 + await async_sandbox.commands.run( + f"python3 -m http.server {port}", background=True, timeout=0 + ) + + # Wait for server to start + await asyncio.sleep(3) + + # Get the public URL for the sandbox + sandbox_url = f"https://{async_sandbox.get_host(port)}" + + async with httpx.AsyncClient() as client: + # Request without traffic access token should succeed (public access enabled) + response = await wait_for_status(client, sandbox_url, 200) + assert response.status_code == 200 + + +@pytest.mark.skip_debug() +async def test_firewall_transform_injects_headers(async_sandbox_factory): + """Test that a firewall rule with a transform injects headers into outbound requests.""" + injected_header = "X-E2B-Test-Token" + injected_value = "e2b-transform-value-123" + + network: SandboxNetworkOpts = { + "rules": { + "httpbin.e2b.team": [ + {"transform": {"headers": {injected_header: injected_value}}}, + ], + }, + } + async_sandbox = await async_sandbox_factory(network=network) + + result = await async_sandbox.commands.run( + "curl -sS --max-time 10 https://httpbin.e2b.team/headers" + ) + assert result.exit_code == 0 + + parsed = json.loads(result.stdout) + reflected = parsed["headers"].get(injected_header) + assert reflected == injected_value, ( + f"expected httpbin to reflect {injected_header}={injected_value}, " + f"got headers: {parsed['headers']}" + ) + + +@pytest.mark.skip_debug() +async def test_update_network_applies_restrictions(async_sandbox_factory): + """update_network can add egress restrictions to a running sandbox.""" + async_sandbox = await async_sandbox_factory() + + # Baseline: 8.8.8.8 reachable. + before = await async_sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8" + ) + assert before.exit_code == 0 + + await async_sandbox.update_network({"deny_out": ["8.8.8.8"]}) + + # 8.8.8.8 is now denied. + with pytest.raises(CommandExitException) as exc_info: + await async_sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8" + ) + assert exc_info.value.exit_code != 0 + + # Other destinations stay reachable. + result = await async_sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert result.exit_code == 0 + + +@pytest.mark.skip_debug() +async def test_update_network_clears_existing_rules(async_sandbox_factory): + """update_network replaces all egress rules; omitted fields are cleared.""" + async_sandbox = await async_sandbox_factory( + network=SandboxNetworkOpts( + deny_out=lambda ctx: [ctx.all_traffic], + allow_out=["1.1.1.1"], + ) + ) + + # Baseline from create-time config: 8.8.8.8 denied. + with pytest.raises(CommandExitException): + await async_sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8" + ) + + # Empty update clears allow_out / deny_out entirely. + await async_sandbox.update_network({}) + + r1 = await async_sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert r1.exit_code == 0 + + r2 = await async_sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8" + ) + assert r2.exit_code == 0 + + +@pytest.mark.skip_debug() +async def test_mask_request_host(async_sandbox_factory): + """Test that mask_request_host modifies the Host header correctly.""" + async_sandbox = await async_sandbox_factory( + network=SandboxNetworkOpts(mask_request_host="custom-host.example.com:${PORT}"), + timeout=60, + ) + + import asyncio + + import httpx + + port = 8080 + output_file = "/tmp/headers.txt" + + # Start a Python HTTP server that captures request headers and writes them to a file + await async_sandbox.commands.run( + f"""python3 -c " +import http.server, json +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + with open('{output_file}', 'w') as f: + for k, v in self.headers.items(): + f.write(k + ': ' + v + chr(10)) + self.send_response(200) + self.end_headers() + def log_message(self, *a): pass +http.server.HTTPServer(('', {port}), H).handle_request() +" """, + background=True, + ) + + await asyncio.sleep(2) + + # Get the public URL for the sandbox + sandbox_url = f"https://{async_sandbox.get_host(port)}" + + # Make a request from OUTSIDE the sandbox through the proxy + # The Host header should be modified according to mask_request_host + async with httpx.AsyncClient() as client: + try: + await client.get(sandbox_url, timeout=5.0) + except Exception: + pass + + await asyncio.sleep(1) + + # Read the captured headers from inside the sandbox + result = await async_sandbox.commands.run(f"cat {output_file}") + + # Verify the Host header was modified according to mask_request_host + assert "Host:" in result.stdout + assert "custom-host.example.com" in result.stdout + assert str(port) in result.stdout diff --git a/packages/python-sdk/tests/async/sandbox_async/test_secure.py b/packages/python-sdk/tests/async/sandbox_async/test_secure.py new file mode 100644 index 0000000..3ca68da --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_secure.py @@ -0,0 +1,26 @@ +import pytest + +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_start_secured(async_sandbox_factory): + sbx = await async_sandbox_factory(timeout=5, secure=True) + + assert await sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + + +@pytest.mark.skip_debug() +async def test_connect_to_secured(async_sandbox_factory): + sbx = await async_sandbox_factory(timeout=100, secure=True) + + assert await sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + + sbx_connection = await AsyncSandbox.connect(sbx.sandbox_id) + assert await sbx_connection.is_running() + assert sbx_connection._envd_version is not None + assert sbx_connection._envd_access_token is not None diff --git a/packages/python-sdk/tests/async/sandbox_async/test_snapshot.py b/packages/python-sdk/tests/async/sandbox_async/test_snapshot.py new file mode 100644 index 0000000..a871b82 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_snapshot.py @@ -0,0 +1,15 @@ +import pytest +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_snapshot(async_sandbox: AsyncSandbox): + assert await async_sandbox.is_running() + + await async_sandbox.pause() + assert not await async_sandbox.is_running() + + resumed_sandbox = await async_sandbox.connect() + assert await async_sandbox.is_running() + assert await resumed_sandbox.is_running() + assert resumed_sandbox.sandbox_id == async_sandbox.sandbox_id diff --git a/packages/python-sdk/tests/async/sandbox_async/test_snapshot_api.py b/packages/python-sdk/tests/async/sandbox_async/test_snapshot_api.py new file mode 100644 index 0000000..e9673b0 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_snapshot_api.py @@ -0,0 +1,164 @@ +import pytest +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_create_snapshot(async_sandbox: AsyncSandbox): + snapshot = await async_sandbox.create_snapshot() + + assert snapshot.snapshot_id + assert len(snapshot.snapshot_id) > 0 + + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +async def test_create_sandbox_from_snapshot(async_sandbox: AsyncSandbox): + test_content = "content from original sandbox" + await async_sandbox.files.write("/home/user/test.txt", test_content) + + snapshot = await async_sandbox.create_snapshot() + + try: + new_sandbox = await AsyncSandbox.create(snapshot.snapshot_id) + + try: + content = await new_sandbox.files.read("/home/user/test.txt") + assert content == test_content + finally: + await new_sandbox.kill() + finally: + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +async def test_create_multiple_sandboxes_from_snapshot(async_sandbox: AsyncSandbox): + test_content = "shared snapshot content" + await async_sandbox.files.write("/home/user/shared.txt", test_content) + + snapshot = await async_sandbox.create_snapshot() + + try: + sandbox1 = await AsyncSandbox.create(snapshot.snapshot_id) + sandbox2 = await AsyncSandbox.create(snapshot.snapshot_id) + + try: + content1 = await sandbox1.files.read("/home/user/shared.txt") + content2 = await sandbox2.files.read("/home/user/shared.txt") + + assert content1 == test_content + assert content2 == test_content + + await sandbox1.files.write("/home/user/shared.txt", "modified in sandbox1") + + modified_content = await sandbox1.files.read("/home/user/shared.txt") + unchanged_content = await sandbox2.files.read("/home/user/shared.txt") + + assert modified_content == "modified in sandbox1" + assert unchanged_content == test_content + finally: + await sandbox1.kill() + await sandbox2.kill() + finally: + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +async def test_list_snapshots(async_sandbox: AsyncSandbox): + snapshot = await async_sandbox.create_snapshot() + + try: + paginator = AsyncSandbox.list_snapshots() + assert paginator.has_next + + snapshots = await paginator.next_items() + assert isinstance(snapshots, list) + + found = any(s.snapshot_id == snapshot.snapshot_id for s in snapshots) + assert found + finally: + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +async def test_list_snapshots_for_sandbox(async_sandbox: AsyncSandbox): + snapshot = await async_sandbox.create_snapshot() + + try: + paginator = AsyncSandbox.list_snapshots( + sandbox_id=async_sandbox.sandbox_id, + ) + snapshots = await paginator.next_items() + + found = any(s.snapshot_id == snapshot.snapshot_id for s in snapshots) + assert found + finally: + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +async def test_create_named_snapshot(async_sandbox: AsyncSandbox, sandbox_test_id: str): + snapshot_name = f"snap-{sandbox_test_id}" + + snapshot = await async_sandbox.create_snapshot(name=snapshot_name) + + try: + assert snapshot.snapshot_id + assert isinstance(snapshot.names, list) + assert len(snapshot.names) > 0 + assert any(snapshot_name in n for n in snapshot.names) + finally: + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +async def test_delete_snapshot(async_sandbox: AsyncSandbox): + snapshot = await async_sandbox.create_snapshot() + + deleted = await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + assert deleted is True + + deleted_again = await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + assert deleted_again is False + + +@pytest.mark.skip_debug() +async def test_snapshot_preserves_filesystem(async_sandbox: AsyncSandbox): + app_dir = "/home/user/app" + config_path = f"{app_dir}/config.json" + config_content = '{"env": "test"}' + data_path = f"{app_dir}/data.txt" + data_content = "important data" + + await async_sandbox.files.make_dir(app_dir) + await async_sandbox.files.write(config_path, config_content) + await async_sandbox.files.write(data_path, data_content) + + snapshot = await async_sandbox.create_snapshot() + + try: + new_sandbox = await AsyncSandbox.create(snapshot.snapshot_id) + + try: + dir_exists = await new_sandbox.files.exists(app_dir) + assert dir_exists + + config = await new_sandbox.files.read(config_path) + data = await new_sandbox.files.read(data_path) + + assert config == config_content + assert data == data_content + finally: + await new_sandbox.kill() + finally: + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +async def test_create_snapshot_class_method(async_sandbox: AsyncSandbox): + snapshot = await AsyncSandbox.create_snapshot(async_sandbox.sandbox_id) + + assert snapshot.snapshot_id + assert len(snapshot.snapshot_id) > 0 + + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) diff --git a/packages/python-sdk/tests/async/sandbox_async/test_snapshot_filesystem_only.py b/packages/python-sdk/tests/async/sandbox_async/test_snapshot_filesystem_only.py new file mode 100644 index 0000000..cdfa597 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_snapshot_filesystem_only.py @@ -0,0 +1,31 @@ +import pytest +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_pause_filesystem_only(async_sandbox: AsyncSandbox): + # A marker on the persisted rootfs and the kernel boot id before pausing. + await async_sandbox.files.write("/home/user/fs-only-marker.txt", "persisted") + boot_before = ( + await async_sandbox.files.read("/proc/sys/kernel/random/boot_id") + ).strip() + + # Filesystem-only pause: only the rootfs is persisted, no memory snapshot. + assert await async_sandbox.pause(keep_memory=False) + assert not await async_sandbox.is_running() + + # Resuming a filesystem-only snapshot cold-boots (reboots) from the rootfs. + resumed = await async_sandbox.connect() + assert await resumed.is_running() + assert resumed.sandbox_id == async_sandbox.sandbox_id + + # connect() returns the same handle, and its credentials stay valid across + # the resume (the backend re-binds the same envd access token on the cold + # boot). The rootfs survives the reboot... + marker = (await resumed.files.read("/home/user/fs-only-marker.txt")).strip() + assert marker == "persisted" + + # ...while a fresh kernel boot id proves the guest cold-booted rather than + # being restored from a memory snapshot. + boot_after = (await resumed.files.read("/proc/sys/kernel/random/boot_id")).strip() + assert boot_after != boot_before diff --git a/packages/python-sdk/tests/async/sandbox_async/test_timeout.py b/packages/python-sdk/tests/async/sandbox_async/test_timeout.py new file mode 100644 index 0000000..7474b08 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_timeout.py @@ -0,0 +1,30 @@ +import pytest +from datetime import datetime + +from time import sleep + +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_shorten_timeout(async_sandbox: AsyncSandbox): + await async_sandbox.set_timeout(5) + sleep(6) + + is_running = await async_sandbox.is_running() + assert is_running is False + + +@pytest.mark.skip_debug() +async def test_shorten_then_lengthen_timeout(async_sandbox: AsyncSandbox): + await async_sandbox.set_timeout(5) + sleep(1) + await async_sandbox.set_timeout(10) + sleep(6) + await async_sandbox.is_running() + + +@pytest.mark.skip_debug() +async def test_get_timeout(async_sandbox: AsyncSandbox): + info = await async_sandbox.get_info() + assert isinstance(info.end_at, datetime) diff --git a/packages/python-sdk/tests/async/template_async/conftest.py b/packages/python-sdk/tests/async/template_async/conftest.py new file mode 100644 index 0000000..520f64f --- /dev/null +++ b/packages/python-sdk/tests/async/template_async/conftest.py @@ -0,0 +1,11 @@ +import os + +import pytest + +_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def pytest_collection_modifyitems(items): + for item in items: + if str(item.fspath).startswith(_DIR): + item.add_marker(pytest.mark.timeout(180)) diff --git a/packages/python-sdk/tests/async/template_async/methods/test_from_dockerfile.py b/packages/python-sdk/tests/async/template_async/methods/test_from_dockerfile.py new file mode 100644 index 0000000..1c9f2fd --- /dev/null +++ b/packages/python-sdk/tests/async/template_async/methods/test_from_dockerfile.py @@ -0,0 +1,133 @@ +import pytest + +from e2b import AsyncTemplate +from e2b.template.types import InstructionType + + +@pytest.mark.skip_debug() +async def test_from_dockerfile(): + dockerfile = """FROM node:24 +WORKDIR /app +COPY package.json . +RUN npm install +ENTRYPOINT ["sleep", "20"]""" + + template = AsyncTemplate().from_dockerfile(dockerfile) + + # base image + assert template._template._base_image == "node:24" + + instructions = template._template._instructions + + # Docker defaults + assert instructions[1]["type"] == InstructionType.WORKDIR + assert instructions[1]["args"][0] == "/" + + # Instructions from Dockerfile + assert instructions[2]["type"] == InstructionType.WORKDIR + assert instructions[2]["args"][0] == "/app" + + assert instructions[3]["type"] == InstructionType.COPY + assert instructions[3]["args"][0] == "package.json" + assert instructions[3]["args"][1] == "." + + assert instructions[4]["type"] == InstructionType.RUN + assert instructions[4]["args"][0] == "npm install" + + # E2B defaults appended + assert instructions[5]["type"] == InstructionType.USER + assert instructions[5]["args"][0] == "user" + + # Start command + assert template._template._start_cmd == "sleep 20" + + +@pytest.mark.skip_debug() +async def test_from_dockerfile_with_default_user_and_workdir(): + dockerfile = "FROM node:24" + + template = AsyncTemplate().from_dockerfile(dockerfile) + + assert template._template._instructions[-2]["type"] == InstructionType.USER + assert template._template._instructions[-2]["args"][0] == "user" + assert template._template._instructions[-1]["type"] == InstructionType.WORKDIR + assert template._template._instructions[-1]["args"][0] == "/home/user" + + +@pytest.mark.skip_debug() +async def test_from_dockerfile_with_custom_user_and_workdir(): + dockerfile = "FROM node:24\nUSER mish\nWORKDIR /home/mish" + + template = AsyncTemplate().from_dockerfile(dockerfile) + + assert template._template._instructions[-2]["type"] == InstructionType.USER + assert template._template._instructions[-2]["args"][0] == "mish" + assert template._template._instructions[-1]["type"] == InstructionType.WORKDIR + assert template._template._instructions[-1]["args"][0] == "/home/mish" + + +@pytest.mark.skip_debug() +async def test_from_dockerfile_with_multi_source_copy(): + dockerfile = """FROM node:24 +COPY file1.txt file2.txt file3.txt /dest/""" + + template = AsyncTemplate().from_dockerfile(dockerfile) + + instructions = template._template._instructions + + copy_instructions = [i for i in instructions if i["type"] == InstructionType.COPY] + + assert len(copy_instructions) == 3 + assert copy_instructions[0]["args"][0] == "file1.txt" + assert copy_instructions[0]["args"][1] == "/dest/" + assert copy_instructions[1]["args"][0] == "file2.txt" + assert copy_instructions[1]["args"][1] == "/dest/" + assert copy_instructions[2]["args"][0] == "file3.txt" + assert copy_instructions[2]["args"][1] == "/dest/" + + +@pytest.mark.skip_debug() +async def test_from_dockerfile_with_multi_source_copy_chown(): + dockerfile = """FROM node:24 +COPY --chown=myuser:mygroup pkg.json pkg-lock.json /app/""" + + template = AsyncTemplate().from_dockerfile(dockerfile) + + instructions = template._template._instructions + + copy_instructions = [i for i in instructions if i["type"] == InstructionType.COPY] + + assert len(copy_instructions) == 2 + assert copy_instructions[0]["args"][0] == "pkg.json" + assert copy_instructions[0]["args"][1] == "/app/" + assert copy_instructions[0]["args"][2] == "myuser:mygroup" + assert copy_instructions[1]["args"][0] == "pkg-lock.json" + assert copy_instructions[1]["args"][1] == "/app/" + assert copy_instructions[1]["args"][2] == "myuser:mygroup" + + +@pytest.mark.skip_debug() +async def test_from_dockerfile_with_copy_chown(): + dockerfile = """FROM node:24 +COPY --chown=myuser:mygroup app.js /app/ +COPY --chown=anotheruser config.json /config/""" + + template = AsyncTemplate().from_dockerfile(dockerfile) + + instructions = template._template._instructions + + # First COPY instruction (after initial USER root and WORKDIR /) + copy_instruction1 = instructions[2] + assert copy_instruction1["type"] == InstructionType.COPY + assert copy_instruction1["args"][0] == "app.js" + assert copy_instruction1["args"][1] == "/app/" + assert copy_instruction1["args"][2] == "myuser:mygroup" # user from --chown + + # Second COPY instruction + copy_instruction2 = instructions[3] + assert copy_instruction2["type"] == InstructionType.COPY + assert copy_instruction2["args"][0] == "config.json" + assert copy_instruction2["args"][1] == "/config/" + assert ( + copy_instruction2["args"][2] == "anotheruser" + ) # user from --chown (without group) diff --git a/packages/python-sdk/tests/async/template_async/methods/test_make_symlink.py b/packages/python-sdk/tests/async/template_async/methods/test_make_symlink.py new file mode 100644 index 0000000..f6c1c61 --- /dev/null +++ b/packages/python-sdk/tests/async/template_async/methods/test_make_symlink.py @@ -0,0 +1,32 @@ +import pytest + +from e2b import AsyncTemplate + + +@pytest.mark.skip_debug() +async def test_make_symlink(async_build): + template = ( + AsyncTemplate() + .from_image("ubuntu:22.04") + .skip_cache() + .make_symlink(".bashrc", ".bashrc.local") + .run_cmd('test "$(readlink .bashrc.local)" = ".bashrc"') + ) + + await async_build(template) + + +@pytest.mark.skip_debug() +async def test_make_symlink_force(async_build): + template = ( + AsyncTemplate() + .from_image("ubuntu:22.04") + .make_symlink(".bashrc", ".bashrc.local") + .skip_cache() + .make_symlink( + ".bashrc", ".bashrc.local", force=True + ) # Overwrite existing symlink + .run_cmd('test "$(readlink .bashrc.local)" = ".bashrc"') + ) + + await async_build(template) diff --git a/packages/python-sdk/tests/async/template_async/methods/test_run_cmd.py b/packages/python-sdk/tests/async/template_async/methods/test_run_cmd.py new file mode 100644 index 0000000..6f29096 --- /dev/null +++ b/packages/python-sdk/tests/async/template_async/methods/test_run_cmd.py @@ -0,0 +1,40 @@ +import pytest + +from e2b import AsyncTemplate + + +@pytest.mark.skip_debug() +async def test_run_command(async_build): + template = AsyncTemplate().from_image("ubuntu:22.04").skip_cache().run_cmd("ls -l") + + await async_build(template) + + +@pytest.mark.skip_debug() +async def test_run_command_as_different_user(async_build): + template = ( + AsyncTemplate() + .from_image("ubuntu:22.04") + .skip_cache() + .run_cmd('test "$(whoami)" = "root"', user="root") + ) + + await async_build(template) + + +@pytest.mark.skip_debug() +async def test_run_command_as_user_that_does_not_exist(async_build): + template = ( + AsyncTemplate() + .from_image("ubuntu:22.04") + .skip_cache() + .run_cmd("whoami", user="root123") + ) + + with pytest.raises(Exception) as exc_info: + await async_build(template) + + assert ( + "failed to run command 'whoami': command failed: unauthenticated: invalid username: 'root123'" + in str(exc_info.value) + ) diff --git a/packages/python-sdk/tests/async/template_async/methods/test_to_dockerfile.py b/packages/python-sdk/tests/async/template_async/methods/test_to_dockerfile.py new file mode 100644 index 0000000..f955d1f --- /dev/null +++ b/packages/python-sdk/tests/async/template_async/methods/test_to_dockerfile.py @@ -0,0 +1,57 @@ +import pytest + +from e2b import AsyncTemplate + + +@pytest.mark.skip_debug() +async def test_to_dockerfile(): + template = ( + AsyncTemplate() + .from_ubuntu_image("24.04") + .copy("README.md", "/app/README.md") + .run_cmd('echo "Hello, World!"') + ) + + dockerfile = AsyncTemplate.to_dockerfile(template) + + expected_dockerfile = """FROM ubuntu:24.04 +COPY README.md /app/README.md +RUN echo "Hello, World!" +""" + assert dockerfile == expected_dockerfile + + +@pytest.mark.skip_debug() +async def test_to_dockerfile_with_options(): + template = ( + AsyncTemplate() + .from_ubuntu_image("24.04") + .copy("README.md", "/app/README.md", user="root") + .run_cmd('echo "Hello, World!"', user="root") + ) + + dockerfile = AsyncTemplate.to_dockerfile(template) + + expected_dockerfile = """FROM ubuntu:24.04 +COPY README.md /app/README.md +RUN echo "Hello, World!" +""" + assert dockerfile == expected_dockerfile + + +@pytest.mark.skip_debug() +async def test_to_dockerfile_with_env_instructions(): + template = ( + AsyncTemplate() + .from_ubuntu_image("24.04") + .set_envs({"NODE_ENV": "production", "PORT": "8080"}) + .set_envs({"DEBUG": "false"}) + ) + + dockerfile = AsyncTemplate.to_dockerfile(template) + + expected_dockerfile = """FROM ubuntu:24.04 +ENV NODE_ENV=production PORT=8080 +ENV DEBUG=false +""" + assert dockerfile == expected_dockerfile diff --git a/packages/python-sdk/tests/async/template_async/test_background_build.py b/packages/python-sdk/tests/async/template_async/test_background_build.py new file mode 100644 index 0000000..16690c7 --- /dev/null +++ b/packages/python-sdk/tests/async/template_async/test_background_build.py @@ -0,0 +1,34 @@ +import uuid + +import pytest + +from e2b import AsyncTemplate, wait_for_timeout + + +@pytest.mark.skip_debug() +@pytest.mark.timeout(10) +async def test_build_in_background_should_start_build_and_return_info(): + """Test that build_in_background returns immediately without waiting for build to complete.""" + template = ( + AsyncTemplate() + .from_image("ubuntu:22.04") + .skip_cache() + .run_cmd("sleep 5") # Add a delay to ensure build takes time + .set_start_cmd('echo "Hello"', wait_for_timeout(10_000)) + ) + + name = f"e2b-test:v1-{uuid.uuid4()}" + + build_info = await AsyncTemplate.build_in_background( + template, + name, + cpu_count=1, + memory_mb=1024, + ) + + # Should return quickly (within a few seconds), not wait for the full build + assert build_info is not None + + # Verify the build is actually running + status = await AsyncTemplate.get_build_status(build_info) + assert status.status.value == "building" diff --git a/packages/python-sdk/tests/async/template_async/test_build.py b/packages/python-sdk/tests/async/template_async/test_build.py new file mode 100644 index 0000000..f16da0b --- /dev/null +++ b/packages/python-sdk/tests/async/template_async/test_build.py @@ -0,0 +1,102 @@ +import os +import shutil +import tempfile + +import pytest + +from e2b import AsyncTemplate, default_build_logger, wait_for_timeout + + +@pytest.fixture(scope="module") +def setup_test_folder(): + test_dir = tempfile.mkdtemp(prefix="python_async_test_") + folder_path = os.path.join(test_dir, "folder") + + os.makedirs(folder_path, exist_ok=True) + with open(os.path.join(folder_path, "test.txt"), "w") as f: + f.write("This is a test file.") + + # Create relative symlink + symlink_path = os.path.join(folder_path, "symlink.txt") + if os.path.exists(symlink_path): + os.remove(symlink_path) + os.symlink("test.txt", symlink_path) + + # Create absolute symlink + symlink2_path = os.path.join(folder_path, "symlink2.txt") + if os.path.exists(symlink2_path): + os.remove(symlink2_path) + os.symlink(os.path.join(folder_path, "test.txt"), symlink2_path) + + # Create a symlink to a file that does not exist + symlink3_path = os.path.join(folder_path, "symlink3.txt") + if os.path.exists(symlink3_path): + os.remove(symlink3_path) + os.symlink("12345test.txt", symlink3_path) + + yield test_dir + + # Cleanup + shutil.rmtree(test_dir, ignore_errors=True) + + +@pytest.mark.skip_debug() +async def test_build_template(async_build, setup_test_folder): + template = ( + AsyncTemplate(file_context_path=setup_test_folder) + .from_base_image() + .copy("folder/*", "folder", force_upload=True) + .run_cmd("cat folder/test.txt") + .set_workdir("/app") + .set_start_cmd("echo 'Hello, world!'", wait_for_timeout(10_000)) + ) + + await async_build(template, skip_cache=True, on_build_logs=default_build_logger()) + + +@pytest.mark.skip_debug() +async def test_build_template_from_base_template(async_build): + template = AsyncTemplate().from_template("base") + await async_build(template, skip_cache=True, on_build_logs=default_build_logger()) + + +@pytest.mark.skip_debug() +async def test_build_template_with_symlinks(async_build, setup_test_folder): + template = ( + AsyncTemplate(file_context_path=setup_test_folder) + .from_image("ubuntu:22.04") + .skip_cache() + .copy("folder/*", "folder", force_upload=True) + .run_cmd("cat folder/symlink.txt") + ) + + await async_build(template) + + +@pytest.mark.skip_debug() +async def test_build_template_with_resolve_symlinks(async_build, setup_test_folder): + template = ( + AsyncTemplate(file_context_path=setup_test_folder) + .from_image("ubuntu:22.04") + .skip_cache() + .copy( + "folder/symlink.txt", + "folder/symlink.txt", + force_upload=True, + resolve_symlinks=True, + ) + .run_cmd("cat folder/symlink.txt") + ) + + await async_build(template) + + +@pytest.mark.skip_debug() +async def test_build_template_with_skip_cache(async_build, setup_test_folder): + template = ( + AsyncTemplate(file_context_path=setup_test_folder) + .skip_cache() + .from_image("ubuntu:22.04") + ) + + await async_build(template) diff --git a/packages/python-sdk/tests/async/template_async/test_exists.py b/packages/python-sdk/tests/async/template_async/test_exists.py new file mode 100644 index 0000000..6da5609 --- /dev/null +++ b/packages/python-sdk/tests/async/template_async/test_exists.py @@ -0,0 +1,20 @@ +import uuid + +import pytest + +from e2b import AsyncTemplate + + +@pytest.mark.skip_debug() +async def test_check_base_template_name_exists(): + """Test that the base template name exists.""" + exists = await AsyncTemplate.exists("base") + assert exists is True + + +@pytest.mark.skip_debug() +async def test_check_non_existing_name(): + """Test that a non-existing name returns False.""" + non_existing_name = f"nonexistent-{uuid.uuid4()}" + exists = await AsyncTemplate.exists(non_existing_name) + assert exists is False diff --git a/packages/python-sdk/tests/async/template_async/test_stacktrace.py b/packages/python-sdk/tests/async/template_async/test_stacktrace.py new file mode 100644 index 0000000..47cef08 --- /dev/null +++ b/packages/python-sdk/tests/async/template_async/test_stacktrace.py @@ -0,0 +1,426 @@ +import traceback +from types import SimpleNamespace +from typing import List, Optional +from uuid import uuid4 + +import pytest +import linecache + +from e2b import AsyncTemplate, CopyItem, wait_for_timeout +from e2b.template.types import TemplateBuildStatus +import e2b.template_async.main as template_async_main +import e2b.template_async.build_api as build_api_mod + +non_existent_path = "nonexistent/path" + +# map template alias -> failed step index +failure_map: dict[str, Optional[int]] = { + "from_image": 0, + "from_template": 0, + "from_dockerfile": 0, + "from_image_registry": 0, + "from_aws_registry": 0, + "from_gcp_registry": 0, + "copy": None, + "copy_items": None, + # multi-source copy produces two COPY instructions (steps 1 and 2), + # the run_cmd after it is step 3 + "multi_source_copy_second_source": 2, + "multi_source_copy_next_step": 3, + "copy_items_second_item": 2, + "copy_items_next_step": 3, + "remove": 1, + "rename": 1, + "make_dir": 1, + "make_symlink": 1, + "run_cmd": 1, + "set_workdir": 1, + "set_user": 1, + "pip_install": 1, + "npm_install": 1, + "apt_install": 1, + "git_clone": 1, + "set_start_cmd": 1, + "add_mcp_server": None, + "beta_dev_container_prebuild": 1, + "beta_set_dev_container_start": 1, +} + + +@pytest.fixture(autouse=True) +def mock_template_build(monkeypatch): + async def mock_request_build( + client, name: str, tags: Optional[List[str]], cpu_count: int, memory_mb: int + ): + return SimpleNamespace(template_id=name, build_id=str(uuid4()), tags=tags or []) + + async def mock_trigger_build(client, template_id: str, build_id: str, template): + return None + + async def mock_get_file_upload_link( + client, template_id: str, files_hash: str, stack_trace=None + ): + return SimpleNamespace(present=True, url=None) + + async def mock_get_build_status( + client, template_id: str, build_id: str, logs_offset: int + ): + step = failure_map[template_id] + reason = SimpleNamespace( + message="Mocked API build error", + log_entries=[], + step=str(step) if step is not None else None, + ) + return SimpleNamespace( + status=TemplateBuildStatus.ERROR, + log_entries=[], + reason=reason, + ) + + monkeypatch.setattr(template_async_main, "request_build", mock_request_build) + monkeypatch.setattr(template_async_main, "trigger_build", mock_trigger_build) + monkeypatch.setattr( + template_async_main, "get_file_upload_link", mock_get_file_upload_link + ) + monkeypatch.setattr(build_api_mod, "get_build_status", mock_get_build_status) + + +async def _expect_to_throw_and_check_trace(func, expected_method: str): + try: + await func() + assert False, "Expected AsyncTemplate.build to raise an exception" + except Exception as e: # noqa: BLE001 - we want to assert on the traceback regardless of type + tb = e.__traceback__ + saw_this_file = False + saw_expected_method = False + while tb is not None: + traceback_file = tb.tb_frame.f_code.co_filename + if traceback_file == __file__: + saw_this_file = True + caller_line = linecache.getline(traceback_file, tb.tb_lineno) + if caller_line and f".{expected_method}(" in caller_line: + saw_expected_method = True + break + tb = tb.tb_next + assert saw_this_file, traceback.format_exc() + assert saw_expected_method, traceback.format_exc() + + +@pytest.mark.skip_debug() +async def test_traces_on_from_image(async_build): + template = AsyncTemplate().from_image("e2b.dev/this-image-does-not-exist") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="from_image", skip_cache=True), "from_image" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_from_template(async_build): + template = AsyncTemplate().from_template("this-template-does-not-exist") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="from_template", skip_cache=True), + "from_template", + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_from_dockerfile(async_build): + template = AsyncTemplate().from_dockerfile("FROM ubuntu:22.04\nRUN nonexistent") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="from_dockerfile", skip_cache=True), + "from_dockerfile", + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_from_image_registry(async_build): + template = AsyncTemplate().from_image( + "registry.example.com/nonexistent:latest", + username="test", + password="test", + ) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="from_image_registry", skip_cache=True), + "from_image", + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_from_image_credentials(): + await _expect_to_throw_and_check_trace( + lambda: AsyncTemplate().from_image("ubuntu:22.04", username="user"), + "from_image", + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_from_aws_registry(async_build): + template = AsyncTemplate().from_aws_registry( + "123456789.dkr.ecr.us-east-1.amazonaws.com/nonexistent:latest", + access_key_id="test", + secret_access_key="test", + region="us-east-1", + ) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="from_aws_registry"), "from_aws_registry" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_from_gcp_registry(async_build): + template = AsyncTemplate().from_gcp_registry( + "gcr.io/nonexistent-project/nonexistent:latest", + service_account_json={ + "type": "service_account", + }, + ) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="from_gcp_registry"), "from_gcp_registry" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_copy(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().copy(non_existent_path, non_existent_path) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="copy"), "copy" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_copyItems(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().copy_items( + [CopyItem(src=non_existent_path, dest=non_existent_path)] + ) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="copy_items"), "copy_items" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_second_source_of_multi_source_copy(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.copy(["test_stacktrace.py", "test_tags.py"], ".") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="multi_source_copy_second_source"), "copy" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_step_after_multi_source_copy(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.copy(["test_stacktrace.py", "test_tags.py"], ".") + template = template.run_cmd(f"cat {non_existent_path}") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="multi_source_copy_next_step"), "run_cmd" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_second_item_of_copy_items(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.copy_items( + [ + CopyItem(src="test_stacktrace.py", dest="."), + CopyItem(src="test_tags.py", dest="."), + ] + ) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="copy_items_second_item"), "copy_items" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_step_after_copy_items(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.copy_items( + [ + CopyItem(src="test_stacktrace.py", dest="."), + CopyItem(src="test_tags.py", dest="."), + ] + ) + template = template.run_cmd(f"cat {non_existent_path}") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="copy_items_next_step"), "run_cmd" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_copy_absolute_path(): + await _expect_to_throw_and_check_trace( + lambda: AsyncTemplate() + .from_base_image() + .copy("/absolute/path", "/absolute/path"), + "copy", + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_copyItems_absolute_path(): + await _expect_to_throw_and_check_trace( + lambda: AsyncTemplate() + .from_base_image() + .copy_items([CopyItem(src="/absolute/path", dest="/absolute/path")]), + "copy_items", + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_remove(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().remove(non_existent_path) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="remove"), "remove" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_rename(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().rename(non_existent_path, "/tmp/dest.txt") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="rename"), "rename" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_make_dir(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.set_user("root").skip_cache().make_dir("/root/.bashrc") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="make_dir"), "make_dir" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_make_symlink(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().make_symlink(".bashrc", ".bashrc") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="make_symlink"), "make_symlink" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_run_cmd(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().run_cmd(f"cat {non_existent_path}") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="run_cmd"), "run_cmd" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_set_workdir(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.set_user("root").skip_cache().set_workdir("/root/.bashrc") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="set_workdir"), "set_workdir" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_set_user(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().set_user("; exit 1") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="set_user"), "set_user" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_pip_install(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().pip_install("nonexistent-package") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="pip_install"), "pip_install" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_npm_install(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().npm_install("nonexistent-package") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="npm_install"), "npm_install" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_apt_install(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().apt_install("nonexistent-package") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="apt_install"), "apt_install" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_git_clone(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().git_clone("https://github.com/repo.git") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="git_clone"), "git_clone" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_start_cmd(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.set_start_cmd( + f"./{non_existent_path}", wait_for_timeout(10_000) + ) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="set_start_cmd"), "set_start_cmd" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_add_mcp_server(): + # needs mcp-gateway as base template, without it no mcp servers can be added + await _expect_to_throw_and_check_trace( + lambda: AsyncTemplate().from_base_image().skip_cache().add_mcp_server("exa"), + "add_mcp_server", + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_dev_container_prebuild(async_build): + template = AsyncTemplate() + template = template.from_template("devcontainer") + template = template.skip_cache().beta_dev_container_prebuild(non_existent_path) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="beta_dev_container_prebuild"), + "beta_dev_container_prebuild", + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_set_dev_container_start(async_build): + template = AsyncTemplate() + template = template.from_template("devcontainer") + template = template.beta_set_dev_container_start(non_existent_path) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, name="beta_set_dev_container_start"), + "beta_set_dev_container_start", + ) diff --git a/packages/python-sdk/tests/async/template_async/test_tags.py b/packages/python-sdk/tests/async/template_async/test_tags.py new file mode 100644 index 0000000..73eba0a --- /dev/null +++ b/packages/python-sdk/tests/async/template_async/test_tags.py @@ -0,0 +1,241 @@ +import uuid +from datetime import datetime, timezone +from unittest.mock import AsyncMock + +import pytest + +from e2b import AsyncTemplate, TemplateTag, TemplateTagInfo, Template +from e2b.exceptions import TemplateException +import e2b.template_async.main as template_async_main + + +class TestAssignTags: + """Tests for AsyncTemplate.assign_tags method.""" + + @pytest.mark.asyncio + async def test_assign_single_tag(self, monkeypatch): + """Test assigning a single tag to a template.""" + mock_assign_tags = AsyncMock( + return_value=TemplateTagInfo( + build_id="00000000-0000-0000-0000-000000000000", + tags=["production"], + ) + ) + + monkeypatch.setattr( + template_async_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr(template_async_main, "assign_tags", mock_assign_tags) + + result = await AsyncTemplate.assign_tags("my-template:v1.0", "production") + + assert result.build_id == "00000000-0000-0000-0000-000000000000" + assert "production" in result.tags + mock_assign_tags.assert_called_once() + _, target, tags = mock_assign_tags.call_args[0] + assert target == "my-template:v1.0" + assert tags == ["production"] + + @pytest.mark.asyncio + async def test_assign_multiple_tags(self, monkeypatch): + """Test assigning multiple tags to a template.""" + mock_assign_tags = AsyncMock( + return_value=TemplateTagInfo( + build_id="00000000-0000-0000-0000-000000000000", + tags=["production", "stable"], + ) + ) + + monkeypatch.setattr( + template_async_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr(template_async_main, "assign_tags", mock_assign_tags) + + result = await AsyncTemplate.assign_tags( + "my-template:v1.0", ["production", "stable"] + ) + + assert result.build_id == "00000000-0000-0000-0000-000000000000" + assert "production" in result.tags + assert "stable" in result.tags + mock_assign_tags.assert_called_once() + _, _, tags = mock_assign_tags.call_args[0] + assert tags == ["production", "stable"] + + +class TestRemoveTags: + """Tests for AsyncTemplate.remove_tags method.""" + + @pytest.mark.asyncio + async def test_remove_single_tag(self, monkeypatch): + """Test deleting a single tag from a template.""" + mock_remove_tags = AsyncMock(return_value=None) + + monkeypatch.setattr( + template_async_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr(template_async_main, "remove_tags", mock_remove_tags) + + await AsyncTemplate.remove_tags("my-template", "production") + + mock_remove_tags.assert_called_once() + _, name, tags = mock_remove_tags.call_args[0] + assert name == "my-template" + assert tags == ["production"] + + @pytest.mark.asyncio + async def test_remove_multiple_tags(self, monkeypatch): + """Test deleting multiple tags from a template.""" + mock_remove_tags = AsyncMock(return_value=None) + + monkeypatch.setattr( + template_async_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr(template_async_main, "remove_tags", mock_remove_tags) + + await AsyncTemplate.remove_tags("my-template", ["production", "staging"]) + + mock_remove_tags.assert_called_once() + _, name, tags = mock_remove_tags.call_args[0] + assert name == "my-template" + assert tags == ["production", "staging"] + + @pytest.mark.asyncio + async def test_remove_tags_error(self, monkeypatch): + """Test that remove_tags raises an error for nonexistent template.""" + mock_remove_tags = AsyncMock( + side_effect=TemplateException("Template not found") + ) + + monkeypatch.setattr( + template_async_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr(template_async_main, "remove_tags", mock_remove_tags) + + with pytest.raises(TemplateException): + await AsyncTemplate.remove_tags("nonexistent", ["tag"]) + + +class TestGetTags: + """Tests for AsyncTemplate.get_tags method.""" + + @pytest.mark.asyncio + async def test_get_tags(self, monkeypatch): + """Test getting tags for a template.""" + mock_get_template_tags = AsyncMock( + return_value=[ + TemplateTag( + tag="v1.0", + build_id="00000000-0000-0000-0000-000000000000", + created_at=datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + ), + TemplateTag( + tag="latest", + build_id="11111111-1111-1111-1111-111111111111", + created_at=datetime(2024, 1, 16, 12, 0, 0, tzinfo=timezone.utc), + ), + ] + ) + + monkeypatch.setattr( + template_async_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr( + template_async_main, "get_template_tags", mock_get_template_tags + ) + + result = await AsyncTemplate.get_tags("my-template") + + assert len(result) == 2 + assert result[0].tag == "v1.0" + assert result[0].build_id == "00000000-0000-0000-0000-000000000000" + assert isinstance(result[0].created_at, datetime) + assert result[1].tag == "latest" + mock_get_template_tags.assert_called_once() + + @pytest.mark.asyncio + async def test_get_tags_error(self, monkeypatch): + """Test that get_tags raises an error for nonexistent template.""" + mock_get_template_tags = AsyncMock( + side_effect=TemplateException("Template not found") + ) + + monkeypatch.setattr( + template_async_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr( + template_async_main, "get_template_tags", mock_get_template_tags + ) + + with pytest.raises(TemplateException): + await AsyncTemplate.get_tags("nonexistent") + + +# Integration tests +class TestTagsIntegration: + """Integration tests for AsyncTemplate tags functionality.""" + + @pytest.mark.skip_debug() + async def test_build_template_with_tags_assign_and_delete(self, async_build): + """Test building a template with tags, assigning new tags, and deleting.""" + template_name = "e2b-tags-test" + initial_tag = f"{template_name}:v1-{uuid.uuid4().hex}" + + # Build a template with initial tag + template = Template().from_base_image() + build_info = await async_build(template, name=initial_tag) + + assert build_info.build_id + assert build_info.template_id + + # Assign additional tags + tag_info = await AsyncTemplate.assign_tags( + initial_tag, ["production", "latest"] + ) + + assert tag_info.build_id + # API returns just the tag portion, not the full alias:tag + assert "production" in tag_info.tags + assert "latest" in tag_info.tags + + @pytest.mark.skip_debug() + async def test_assign_single_tag_to_existing_template(self, async_build): + """Test assigning a single tag (not array) to an existing template.""" + template_name = "e2b-tags-test" + initial_tag = f"{template_name}:v1-{uuid.uuid4().hex}" + + template = Template().from_base_image() + await async_build(template, name=initial_tag) + + # Assign single tag (not array) + tag_info = await AsyncTemplate.assign_tags(initial_tag, "stable") + + assert tag_info.build_id + # API returns just the tag portion, not the full alias:tag + assert "stable" in tag_info.tags + + @pytest.mark.skip_debug() + async def test_rejects_invalid_tag_format_missing_alias(self, async_build): + """Test that tag without alias (starts with colon) is rejected.""" + template_name = "e2b-tags-test" + initial_tag = f"{template_name}:v1-{uuid.uuid4().hex}" + + template = Template().from_base_image() + await async_build(template, name=initial_tag) + + # Tag without alias (starts with colon) should be rejected + with pytest.raises(Exception): + await AsyncTemplate.assign_tags(initial_tag, ":invalid-tag") + + @pytest.mark.skip_debug() + async def test_rejects_invalid_tag_format_missing_tag(self, async_build): + """Test that tag without tag portion (ends with colon) is rejected.""" + template_name = "e2b-tags-test" + initial_tag = f"{template_name}:v1-{uuid.uuid4().hex}" + + template = Template().from_base_image() + await async_build(template, name=initial_tag) + + # Tag without tag portion (ends with colon) should be rejected + with pytest.raises(Exception): + await AsyncTemplate.assign_tags(initial_tag, f"{template_name}:") diff --git a/packages/python-sdk/tests/async/template_async/test_upload_file.py b/packages/python-sdk/tests/async/template_async/test_upload_file.py new file mode 100644 index 0000000..4b61e7c --- /dev/null +++ b/packages/python-sdk/tests/async/template_async/test_upload_file.py @@ -0,0 +1,197 @@ +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from unittest import mock + +import httpx + +from e2b.api.client.client import AuthenticatedClient +from e2b.template import utils as template_utils +from e2b.template.consts import FILE_UPLOAD_TIMEOUT_SECONDS +from e2b.template_async.build_api import upload_file + + +# Regression test for e2b-dev/e2b#1243 — upload_file must set Content-Length +# and must not fall back to Transfer-Encoding: chunked. S3 presigned PUT URLs +# reject chunked encoding with 501 NotImplemented. The archive is streamed +# from a temporary file on disk with a known Content-Length instead of being +# buffered in memory; this test guards that contract. +# +# The mock server runs in a daemon thread and doesn't need to be async — the +# httpx.AsyncClient connects to it via asyncio sockets without blocking the +# event loop. + + +def _make_server(): + state = {"headers": None, "body_length": 0} + + class Handler(BaseHTTPRequestHandler): + def do_PUT(self): + state["headers"] = dict(self.headers) + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) if length else b"" + state["body_length"] = len(body) + self.send_response(200) + self.end_headers() + + def log_message(self, *args, **kwargs): + return + + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread, state + + +async def test_upload_file_sets_content_length_and_no_chunked_encoding(tmp_path): + (tmp_path / "hello.txt").write_text("hello world") + + server, thread, state = _make_server() + host, port = server.server_address + url = f"http://{host}:{port}/upload" + + try: + + class UploadClient(AuthenticatedClient): + def get_async_httpx_client(self): + raise AssertionError("signed uploads should not use the API client") + + client = UploadClient(base_url="http://test", token="test") + await upload_file( + api_client=client, + file_name="*.txt", + context_path=str(tmp_path), + url=url, + ignore_patterns=[], + resolve_symlinks=False, + gzip=True, + stack_trace=None, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert state["headers"] is not None + content_length = state["headers"].get("Content-Length") + assert content_length is not None + assert int(content_length) > 0 + assert int(content_length) == state["body_length"] + + transfer_encoding = state["headers"].get("Transfer-Encoding") + if transfer_encoding is not None: + assert "chunked" not in transfer_encoding.lower() + assert "Authorization" not in state["headers"] + + +async def _capture_upload_timeout(tmp_path, request_timeout=None): + """Run upload_file against a local server, capturing the httpx timeout.""" + (tmp_path / "hello.txt").write_text("hello world") + + server, thread, state = _make_server() + host, port = server.server_address + url = f"http://{host}:{port}/upload" + + captured = {} + real_client = httpx.AsyncClient + + def spy_client(*args, **kwargs): + captured["timeout"] = kwargs.get("timeout") + return real_client(*args, **kwargs) + + try: + client = AuthenticatedClient(base_url="http://test", token="test") + kwargs = {} + if request_timeout is not None: + kwargs["request_timeout"] = request_timeout + with mock.patch( + "e2b.template_async.build_api.httpx.AsyncClient", side_effect=spy_client + ): + await upload_file( + api_client=client, + file_name="*.txt", + context_path=str(tmp_path), + url=url, + ignore_patterns=[], + resolve_symlinks=False, + gzip=True, + stack_trace=None, + **kwargs, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + return captured["timeout"] + + +async def test_upload_file_defaults_to_one_hour_timeout(tmp_path): + # Uploads of large archives need far longer than the 60s general API + # timeout, so the default upload timeout is 1 hour (matches the JS SDK). + timeout = await _capture_upload_timeout(tmp_path) + assert timeout == httpx.Timeout(FILE_UPLOAD_TIMEOUT_SECONDS) + + +async def test_upload_file_honors_explicit_request_timeout(tmp_path): + # An explicitly set request_timeout overrides the 1-hour upload default. + timeout = await _capture_upload_timeout(tmp_path, request_timeout=5.0) + assert timeout == httpx.Timeout(5.0) + + +async def test_upload_file_ignores_post_upload_close_failure(tmp_path): + # Regression test: once S3 has accepted the archive, closing the spooled + # temp file in the `finally` block can raise. That failure must not be + # wrapped as a FileUploadException — the upload already succeeded. + (tmp_path / "hello.txt").write_text("hello world") + + server, thread, state = _make_server() + host, port = server.server_address + url = f"http://{host}:{port}/upload" + + real_tar_file_stream = template_utils.tar_file_stream + + class _FailingCloseFile: + # Proxies a real spooled temp file but raises on close(). The + # underlying file object is a C type whose `close` attribute can't + # be reassigned, so we wrap it instead. + def __init__(self, inner): + self._inner = inner + + def __getattr__(self, name): + return getattr(self._inner, name) + + def __iter__(self): + return iter(self._inner) + + def close(self): + # Run the real close so we don't leak the temp file, then + # simulate a close failure surfacing from the `finally`. + self._inner.close() + raise OSError("close failed") + + def failing_close_stream(*args, **kwargs): + return _FailingCloseFile(real_tar_file_stream(*args, **kwargs)) + + try: + client = AuthenticatedClient(base_url="http://test", token="test") + with mock.patch( + "e2b.template_async.build_api.tar_file_stream", + side_effect=failing_close_stream, + ): + # Must not raise despite close() failing after a 200 response. + await upload_file( + api_client=client, + file_name="*.txt", + context_path=str(tmp_path), + url=url, + ignore_patterns=[], + resolve_symlinks=False, + gzip=True, + stack_trace=None, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert state["headers"] is not None diff --git a/packages/python-sdk/tests/async/volume_async/test_file.py b/packages/python-sdk/tests/async/volume_async/test_file.py new file mode 100644 index 0000000..f901dcb --- /dev/null +++ b/packages/python-sdk/tests/async/volume_async/test_file.py @@ -0,0 +1,298 @@ +import datetime +from io import BytesIO, StringIO + +import pytest + +from e2b import AsyncVolume +from e2b.exceptions import NotFoundException, VolumeException + + +class TestWriteFileAndReadFile: + async def test_write_and_read_text_file(self, async_volume: AsyncVolume): + path = "/test.txt" + content = "Hello, World!" + + await async_volume.write_file(path, content) + read_content = await async_volume.read_file(path, format="text") + + assert read_content == content + + async def test_write_and_read_bytes(self, async_volume: AsyncVolume): + path = "/test-bytes.txt" + content = "Test bytes content" + content_bytes = content.encode("utf-8") + + await async_volume.write_file(path, content_bytes) + read_bytes = await async_volume.read_file(path, format="bytes") + + assert read_bytes == content_bytes + + async def test_write_and_read_binary_data(self, async_volume: AsyncVolume): + path = "/binary.bin" + binary_data = bytes([0, 1, 2, 3, 4]) + + await async_volume.write_file(path, binary_data) + read_bytes = await async_volume.read_file(path, format="bytes") + + assert read_bytes == binary_data + + async def test_write_and_read_stream(self, async_volume: AsyncVolume): + path = "/test-stream.txt" + content = "Test stream content" + stream = BytesIO(content.encode("utf-8")) + + await async_volume.write_file(path, stream) + read_stream = await async_volume.read_file(path, format="stream") + chunks = [] + async for chunk in read_stream: + chunks.append(chunk) + read_content = b"".join(chunks).decode("utf-8") + + assert read_content == content + + async def test_write_and_read_text_stream(self, async_volume: AsyncVolume): + path = "/test-text-stream.txt" + content = "Test text stream content" + stream = StringIO(content) + + await async_volume.write_file(path, stream) + read_content = await async_volume.read_file(path, format="text") + + assert read_content == content + + async def test_write_and_read_empty_file(self, async_volume: AsyncVolume): + path = "/empty.txt" + content = "" + + await async_volume.write_file(path, content) + read_content = await async_volume.read_file(path, format="text") + + assert read_content == content + + async def test_overwrite_with_force(self, async_volume: AsyncVolume): + path = "/overwrite.txt" + initial_content = "Initial content" + new_content = "New content" + + await async_volume.write_file(path, initial_content) + await async_volume.write_file(path, new_content, force=True) + read_content = await async_volume.read_file(path, format="text") + + assert read_content == new_content + + async def test_write_existing_file_without_force_raises( + self, async_volume: AsyncVolume + ): + path = "/no-overwrite.txt" + initial_content = "Initial content" + new_content = "New content" + + await async_volume.write_file(path, initial_content) + with pytest.raises(VolumeException): + await async_volume.write_file(path, new_content, force=False) + + async def test_write_file_with_metadata(self, async_volume: AsyncVolume): + path = "/metadata.txt" + content = "File with metadata" + + await async_volume.write_file(path, content, uid=1000, gid=1000, mode=0o644) + + entry_info = await async_volume.get_info(path) + assert entry_info.type.value == "file" + assert entry_info.uid == 1000 + assert entry_info.gid == 1000 + assert entry_info.mode == 0o644 + + async def test_write_file_in_nested_directory(self, async_volume: AsyncVolume): + dir_path = "/nested/deep/path" + file_path = f"{dir_path}/file.txt" + content = "Nested file content" + + await async_volume.make_dir(dir_path, force=True) + await async_volume.write_file(file_path, content) + read_content = await async_volume.read_file(file_path, format="text") + + assert read_content == content + + +class TestGetInfo: + async def test_get_info_for_file(self, async_volume: AsyncVolume): + path = "/info-file.txt" + content = "File for info test" + + await async_volume.write_file(path, content) + info = await async_volume.get_info(path) + + assert info.name == "info-file.txt" + assert info.type.value == "file" + assert info.path == path + assert isinstance(info.mtime, datetime.datetime) + assert isinstance(info.ctime, datetime.datetime) + + async def test_get_info_for_directory(self, async_volume: AsyncVolume): + path = "/info-dir" + + await async_volume.make_dir(path) + info = await async_volume.get_info(path) + + assert info.name == "info-dir" + assert info.type.value == "directory" + assert info.path == path + + async def test_exists_returns_false_for_nonexistent( + self, async_volume: AsyncVolume + ): + assert await async_volume.exists("/non-existent.txt") is False + + +class TestUpdateMetadata: + async def test_update_file_metadata(self, async_volume: AsyncVolume): + path = "/metadata-update.txt" + await async_volume.write_file(path, "Content") + + updated = await async_volume.update_metadata( + path, uid=1001, gid=1001, mode=0o755 + ) + + assert updated.path == path + assert updated.type.value == "file" + assert updated.uid == 1001 + assert updated.gid == 1001 + assert updated.mode == 0o755 + + async def test_update_metadata_nonexistent_raises(self, async_volume: AsyncVolume): + with pytest.raises(NotFoundException): + await async_volume.update_metadata("/non-existent.txt", mode=0o644) + + +class TestMakeDir: + async def test_create_directory(self, async_volume: AsyncVolume): + path = "/test-dir" + + await async_volume.make_dir(path) + info = await async_volume.get_info(path) + + assert info.type.value == "directory" + assert info.path == path + + async def test_create_nested_directories_with_force( + self, async_volume: AsyncVolume + ): + path = "/nested/deep/directory" + + await async_volume.make_dir(path, force=True) + info = await async_volume.get_info(path) + + assert info.type.value == "directory" + + async def test_create_existing_directory_without_force_raises( + self, async_volume: AsyncVolume + ): + path = "/existing-dir" + + await async_volume.make_dir(path) + with pytest.raises(VolumeException): + await async_volume.make_dir(path, force=False) + + async def test_create_directory_with_metadata(self, async_volume: AsyncVolume): + path = "/dir-with-metadata" + + await async_volume.make_dir(path, uid=1000, gid=1000, mode=0o755) + + info = await async_volume.get_info(path) + assert info.type.value == "directory" + assert info.uid == 1000 + assert info.gid == 1000 + assert info.mode & 0o777 == 0o755 + + +class TestList: + async def test_list_directory_contents(self, async_volume: AsyncVolume): + await async_volume.write_file("/file1.txt", "Content 1") + await async_volume.write_file("/file2.txt", "Content 2") + await async_volume.make_dir("/dir1") + + entries = await async_volume.list("/") + + assert len(entries) >= 3 + file_names = sorted([e.name for e in entries]) + assert "file1.txt" in file_names + assert "file2.txt" in file_names + assert "dir1" in file_names + + async def test_list_nested_directory(self, async_volume: AsyncVolume): + await async_volume.make_dir("/nested", force=True) + await async_volume.write_file("/nested/file.txt", "Content") + + entries = await async_volume.list("/nested") + + assert len(entries) >= 1 + assert any(e.name == "file.txt" for e in entries) + + @pytest.mark.skip(reason="depth option not yet supported") + async def test_list_with_depth(self, async_volume: AsyncVolume): + await async_volume.make_dir("/deep/nested/structure", force=True) + await async_volume.write_file("/deep/nested/structure/file.txt", "Content") + + entries = await async_volume.list("/deep", depth=2) + + assert len(entries) > 0 + + async def test_list_nonexistent_raises(self, async_volume: AsyncVolume): + with pytest.raises(NotFoundException): + await async_volume.list("/non-existent") + + +class TestRemove: + async def test_remove_file(self, async_volume: AsyncVolume): + path = "/to-remove.txt" + await async_volume.write_file(path, "Content") + + await async_volume.remove(path) + + assert await async_volume.exists(path) is False + + async def test_remove_directory(self, async_volume: AsyncVolume): + path = "/to-remove-dir" + await async_volume.make_dir(path) + + await async_volume.remove(path) + + assert await async_volume.exists(path) is False + + async def test_remove_directory_recursively(self, async_volume: AsyncVolume): + dir_path = "/recursive-dir" + await async_volume.make_dir(f"{dir_path}/nested", force=True) + await async_volume.write_file(f"{dir_path}/nested/file.txt", "Content") + + await async_volume.remove(dir_path) + + assert await async_volume.exists(dir_path) is False + + async def test_remove_nonexistent_raises(self, async_volume: AsyncVolume): + with pytest.raises(NotFoundException): + await async_volume.remove("/non-existent.txt") + + +class TestFileOperationsLifecycle: + async def test_directory_with_multiple_files(self, async_volume: AsyncVolume): + dir_path = "/multi-file-dir" + await async_volume.make_dir(dir_path) + + files = ["file1.txt", "file2.txt", "file3.txt"] + for file_name in files: + await async_volume.write_file( + f"{dir_path}/{file_name}", f"Content of {file_name}" + ) + + entries = await async_volume.list(dir_path) + assert len(entries) >= len(files) + + for file_name in files: + content = await async_volume.read_file( + f"{dir_path}/{file_name}", format="text" + ) + assert content == f"Content of {file_name}" + + await async_volume.remove(dir_path) + assert await async_volume.exists(dir_path) is False diff --git a/packages/python-sdk/tests/async/volume_async/test_volume.py b/packages/python-sdk/tests/async/volume_async/test_volume.py new file mode 100644 index 0000000..af315e3 --- /dev/null +++ b/packages/python-sdk/tests/async/volume_async/test_volume.py @@ -0,0 +1,177 @@ +from http import HTTPStatus +from uuid import uuid4 + +import pytest + +from e2b import AsyncVolume +from e2b.exceptions import NotFoundException +from e2b.api.client.models.volume_and_token import VolumeAndToken +from e2b.api.client.types import Response +import e2b.api.client.api.volumes.post_volumes as post_volumes_mod +import e2b.api.client.api.volumes.get_volumes as get_volumes_mod +import e2b.api.client.api.volumes.get_volumes_volume_id as get_volume_mod +import e2b.api.client.api.volumes.delete_volumes_volume_id as delete_volume_mod + +# In-memory store for mock volumes +_volumes: dict[str, VolumeAndToken] = {} + + +@pytest.fixture(autouse=True) +def mock_volume_api(monkeypatch, test_api_key): + monkeypatch.setenv("E2B_API_KEY", test_api_key) + _volumes.clear() + + async def mock_post_volumes(*, client, body): + vol_id = str(uuid4()) + token = f"vol-token-{uuid4()}" + vol = VolumeAndToken(volume_id=vol_id, name=body.name, token=token) + _volumes[vol_id] = vol + return Response( + status_code=HTTPStatus(201), + content=b"", + headers={}, + parsed=vol, + ) + + async def mock_get_volumes(*, client): + return Response( + status_code=HTTPStatus(200), + content=b"", + headers={}, + parsed=list(_volumes.values()), + ) + + async def mock_get_volume(volume_id, *, client): + vol = _volumes.get(volume_id) + if vol is None: + return Response( + status_code=HTTPStatus(404), + content=b"", + headers={}, + parsed=None, + ) + return Response( + status_code=HTTPStatus(200), + content=b"", + headers={}, + parsed=vol, + ) + + async def mock_delete_volume(volume_id, *, client): + if volume_id not in _volumes: + return Response( + status_code=HTTPStatus(404), + content=b"", + headers={}, + parsed=None, + ) + del _volumes[volume_id] + return Response( + status_code=HTTPStatus(204), + content=b"", + headers={}, + parsed=None, + ) + + monkeypatch.setattr(post_volumes_mod, "asyncio_detailed", mock_post_volumes) + monkeypatch.setattr(get_volumes_mod, "asyncio_detailed", mock_get_volumes) + monkeypatch.setattr(get_volume_mod, "asyncio_detailed", mock_get_volume) + monkeypatch.setattr(delete_volume_mod, "asyncio_detailed", mock_delete_volume) + + +async def test_create_volume(): + vol = await AsyncVolume.create("test-volume") + + assert vol is not None + assert vol.volume_id is not None + assert vol.name == "test-volume" + assert vol.token is not None + + +async def test_get_volume_info(): + created = await AsyncVolume.create("info-volume") + info = await AsyncVolume.get_info(created.volume_id) + + assert info.volume_id == created.volume_id + assert info.name == "info-volume" + assert info.token is not None + + +async def test_list_volumes(): + await AsyncVolume.create("vol-a") + await AsyncVolume.create("vol-b") + + volumes = await AsyncVolume.list() + + assert len(volumes) == 2 + names = sorted([v.name for v in volumes]) + assert names == ["vol-a", "vol-b"] + + +async def test_list_volumes_empty(): + volumes = await AsyncVolume.list() + assert len(volumes) == 0 + + +async def test_destroy_volume(): + vol = await AsyncVolume.create("to-delete") + result = await AsyncVolume.destroy(vol.volume_id) + + assert result is True + + volumes = await AsyncVolume.list() + assert len(volumes) == 0 + + +async def test_destroy_nonexistent_volume(): + result = await AsyncVolume.destroy("non-existent-id") + assert result is False + + +async def test_get_info_nonexistent_volume(): + with pytest.raises(NotFoundException): + await AsyncVolume.get_info("non-existent-id") + + +async def test_create_volume_keeps_proxy_for_content_calls(): + vol = await AsyncVolume.create( + "proxy-volume", proxy="http://user:pass@127.0.0.1:8080" + ) + + # The proxy is stored on the instance... + assert vol._proxy == "http://user:pass@127.0.0.1:8080" + + # ...and instance methods (which build a VolumeConnectionConfig with no + # per-call proxy) pick it up rather than falling back to no proxy. + config = vol._get_volume_config() + assert config.proxy == "http://user:pass@127.0.0.1:8080" + + +async def test_volume_per_call_proxy_overrides_instance(): + vol = await AsyncVolume.create("proxy-volume", proxy="http://127.0.0.1:8080") + + config = vol._get_volume_config(proxy="http://127.0.0.1:9090") + assert config.proxy == "http://127.0.0.1:9090" + + +async def test_volume_full_lifecycle(): + # Create + vol = await AsyncVolume.create("lifecycle-vol") + assert vol.name == "lifecycle-vol" + + # Get info + info = await AsyncVolume.get_info(vol.volume_id) + assert info.name == "lifecycle-vol" + + # List + volumes = await AsyncVolume.list() + assert len(volumes) == 1 + assert volumes[0].volume_id == vol.volume_id + + # Destroy + destroyed = await AsyncVolume.destroy(vol.volume_id) + assert destroyed is True + + # List again + volumes = await AsyncVolume.list() + assert len(volumes) == 0 diff --git a/packages/python-sdk/tests/async/volume_async/test_volume_content.py b/packages/python-sdk/tests/async/volume_async/test_volume_content.py new file mode 100644 index 0000000..c306bb8 --- /dev/null +++ b/packages/python-sdk/tests/async/volume_async/test_volume_content.py @@ -0,0 +1,70 @@ +import httpx +import pytest + +from e2b import AsyncVolume +from e2b.exceptions import NotFoundException +import e2b.volume.volume_async as volume_async_mod + +_files = { + "hello.txt": b"hello world", + "empty.txt": b"", +} + + +def _handler(request: httpx.Request) -> httpx.Response: + path = request.url.params.get("path") + content = _files.get(path) + if content is None: + return httpx.Response(404) + return httpx.Response(200, content=content) + + +@pytest.fixture +def volume(monkeypatch) -> AsyncVolume: + real_get_api_client = volume_async_mod.get_volume_api_client + + def mock_get_api_client(config, **kwargs): + client = real_get_api_client(config, **kwargs) + client.set_async_httpx_client( + httpx.AsyncClient( + base_url=config.api_url, + transport=httpx.MockTransport(_handler), + ) + ) + return client + + monkeypatch.setattr(volume_async_mod, "get_volume_api_client", mock_get_api_client) + return AsyncVolume(volume_id="vol-1", name="test-volume", token="vol-token") + + +async def test_read_file_stream_yields_content(volume: AsyncVolume): + stream = await volume.read_file("hello.txt", format="stream") + chunks = [chunk async for chunk in stream] + assert b"".join(chunks) == b"hello world" + + +async def test_read_file_stream_raises_for_missing_path(volume: AsyncVolume): + with pytest.raises(NotFoundException): + async for _ in await volume.read_file("missing.txt", format="stream"): + pass + + +async def test_read_file_stream_of_empty_file(volume: AsyncVolume): + stream = await volume.read_file("empty.txt", format="stream") + chunks = [chunk async for chunk in stream] + assert b"".join(chunks) == b"" + + +async def test_read_file_text_and_bytes(volume: AsyncVolume): + assert await volume.read_file("hello.txt") == "hello world" + assert await volume.read_file("hello.txt", format="bytes") == b"hello world" + + +async def test_read_file_text_and_bytes_of_empty_file(volume: AsyncVolume): + assert await volume.read_file("empty.txt") == "" + assert await volume.read_file("empty.txt", format="bytes") == b"" + + +async def test_read_file_missing_path_raises(volume: AsyncVolume): + with pytest.raises(NotFoundException): + await volume.read_file("missing.txt") diff --git a/packages/python-sdk/tests/bugs/test_envelope_decode.py b/packages/python-sdk/tests/bugs/test_envelope_decode.py new file mode 100644 index 0000000..6bc3595 --- /dev/null +++ b/packages/python-sdk/tests/bugs/test_envelope_decode.py @@ -0,0 +1,45 @@ +from e2b.sandbox.commands.command_handle import CommandExitException + +import pytest + +from e2b import Sandbox + + +class Desktop(Sandbox): + default_template = "desktop" + + @staticmethod + def _wrap_pyautogui_code(code: str): + return f""" +import pyautogui +import os +import Xlib.display + +display = Xlib.display.Display(os.environ["DISPLAY"]) +pyautogui._pyautogui_x11._display = display + +{code} +exit(0) +""" + + def pyautogui(self, pyautogui_code: str): + code_path = "/home/user/code-4f3a0850-1a83-47b2-8402-67b039a084ae.py" + print(code_path) + + code = self._wrap_pyautogui_code(pyautogui_code) + + self.files.write(code_path, code) + + self.commands.run(f"python {code_path}") + + +@pytest.mark.skip +def test_envelope_decode(): + with Desktop(timeout=30) as desktop: + for _ in range(10): + with pytest.raises(CommandExitException): + desktop.pyautogui( + """ +pyautogui.write("Hello, ") +""" + ) diff --git a/packages/python-sdk/tests/conftest.py b/packages/python-sdk/tests/conftest.py new file mode 100644 index 0000000..4841ee8 --- /dev/null +++ b/packages/python-sdk/tests/conftest.py @@ -0,0 +1,280 @@ +import asyncio +import os +import random +import string +from typing import Callable, Dict, Optional + +import pytest +import pytest_asyncio + +from e2b import ( + AsyncCommandHandle, + AsyncSandbox, + AsyncTemplate, + AsyncVolume, + CommandExitException, + CommandHandle, + LogEntry, + Sandbox, + Template, + TemplateClass, + Volume, +) + + +@pytest.fixture +def test_api_key() -> str: + """Placeholder API key with a valid format for tests that don't hit the API.""" + return "e2b_" + "0" * 40 + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + rep = outcome.get_result() + if rep.when == "call": + item._test_failed = rep.failed + + +@pytest.fixture() +def sandbox_test_id(): + return f"test_{_generate_random_string()}" + + +@pytest.fixture() +def template(): + return "base" + + +@pytest.fixture() +def sandbox_factory(request, template, sandbox_test_id): + def factory(*, template_name: str = template, **kwargs): + metadata = kwargs.setdefault("metadata", dict()) + metadata.setdefault("sandbox_test_id", sandbox_test_id) + + sandbox = Sandbox.create(template_name, **kwargs) + + def finalizer(): + if getattr(request.node, "_test_failed", False): + print(f"\n[TEST FAILED] Sandbox ID: {sandbox.sandbox_id}") + sandbox.kill() + + request.addfinalizer(finalizer) + + return sandbox + + return factory + + +@pytest.fixture() +def sandbox(sandbox_factory): + return sandbox_factory() + + +@pytest_asyncio.fixture +async def async_sandbox_factory(request, template, sandbox_test_id): + sandboxes: list = [] + + async def factory(*, template_name: str = template, **kwargs): + metadata = kwargs.setdefault("metadata", dict()) + metadata.setdefault("sandbox_test_id", sandbox_test_id) + + sandbox = await AsyncSandbox.create(template_name, **kwargs) + sandboxes.append(sandbox) + return sandbox + + yield factory + + if getattr(request.node, "_test_failed", False): + for sandbox in sandboxes: + print(f"\n[TEST FAILED] Sandbox ID: {sandbox.sandbox_id}") + + results = await asyncio.gather( + *(sandbox.kill() for sandbox in sandboxes), return_exceptions=True + ) + for sandbox, result in zip(sandboxes, results): + if isinstance(result, BaseException): + print(f"\n[TEARDOWN FAILED] Sandbox ID: {sandbox.sandbox_id}: {result!r}") + + +@pytest_asyncio.fixture +async def async_sandbox(async_sandbox_factory): + return await async_sandbox_factory() + + +@pytest.fixture +def build(): + def _build( + template: TemplateClass, + name: Optional[str] = None, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + ): + build_name = name or f"e2b-test-{_generate_random_string()}" + build_info: Dict[str, Optional[str]] = {"template_id": None, "build_id": None} + + def capture_logs(log: LogEntry): + import re + + if "Template created with ID:" in log.message: + match = re.search( + r"Template created with ID: ([^,]+), Build ID: (.+)", log.message + ) + if match: + build_info["template_id"] = match.group(1) + build_info["build_id"] = match.group(2) + if on_build_logs: + on_build_logs(log) + + try: + return Template.build( + template, + build_name, + cpu_count=1, + memory_mb=1024, + skip_cache=skip_cache, + on_build_logs=capture_logs, + ) + except Exception as e: + print( + f"\n[BUILD FAILED] name={build_name}, " + f"template_id={build_info['template_id']}, " + f"build_id={build_info['build_id']}, error={e}" + ) + raise + + return _build + + +@pytest_asyncio.fixture +def async_build(): + async def _async_build( + template: TemplateClass, + name: Optional[str] = None, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + ): + build_name = name or f"e2b-test-{_generate_random_string()}" + build_info: Dict[str, Optional[str]] = {"template_id": None, "build_id": None} + + def capture_logs(log: LogEntry): + import re + + if "Template created with ID:" in log.message: + match = re.search( + r"Template created with ID: ([^,]+), Build ID: (.+)", log.message + ) + if match: + build_info["template_id"] = match.group(1) + build_info["build_id"] = match.group(2) + if on_build_logs: + on_build_logs(log) + + try: + return await AsyncTemplate.build( + template, + build_name, + cpu_count=1, + memory_mb=1024, + skip_cache=skip_cache, + on_build_logs=capture_logs, + ) + except Exception as e: + print( + f"\n[BUILD FAILED] name={build_name}, " + f"template_id={build_info['template_id']}, " + f"build_id={build_info['build_id']}, error={e}" + ) + raise + + return _async_build + + +@pytest.fixture +def debug(): + return os.getenv("E2B_DEBUG") is not None + + +@pytest.fixture(autouse=True) +def skip_by_debug(request, debug): + if request.node.get_closest_marker("skip_debug"): + if debug: + pytest.skip("skipped because E2B_DEBUG is set") + + +class Helpers: + @staticmethod + def catch_cmd_exit_error_in_background(cmd: AsyncCommandHandle): + disabled = False + + async def wait_for_exit(): + try: + await cmd.wait() + except CommandExitException as e: + if not disabled: + assert False, ( + f"command failed with exit code {e.exit_code}: {e.stderr}" + ) + + asyncio.create_task(wait_for_exit()) + + def disable(): + nonlocal disabled + disabled = True + + return disable + + @staticmethod + def check_cmd_exit_error(cmd: CommandHandle): + try: + cmd.wait() + except CommandExitException as e: + assert False, f"command failed with exit code {e.exit_code}: {e.stderr}" + except Exception as e: + raise e + + +@pytest.fixture +def helpers(): + return Helpers + + +def _generate_random_string(length: int = 8) -> str: + return "".join(random.choices(string.ascii_lowercase + string.digits, k=length)) + + +def _skip_unless_volume_tests_enabled(): + if os.getenv("ENABLE_VOLUME_TESTS") is None: + pytest.skip("skipped because ENABLE_VOLUME_TESTS is not set") + + +@pytest.fixture +def volume(request): + _skip_unless_volume_tests_enabled() + vol = Volume.create(f"test-vol-{_generate_random_string()}") + + def finalizer(): + if getattr(request.node, "_test_failed", False): + print(f"\n[TEST FAILED] Volume ID: {vol.volume_id}") + try: + Volume.destroy(vol.volume_id) + except Exception: + pass + + request.addfinalizer(finalizer) + return vol + + +@pytest_asyncio.fixture +async def async_volume(request): + _skip_unless_volume_tests_enabled() + vol = await AsyncVolume.create(f"test-vol-{_generate_random_string()}") + try: + yield vol + finally: + if getattr(request.node, "_test_failed", False): + print(f"\n[TEST FAILED] Volume ID: {vol.volume_id}") + try: + await AsyncVolume.destroy(vol.volume_id) + except Exception: + pass diff --git a/packages/python-sdk/tests/e2b_connect/__init__.py b/packages/python-sdk/tests/e2b_connect/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/python-sdk/tests/e2b_connect/test_client.py b/packages/python-sdk/tests/e2b_connect/test_client.py new file mode 100644 index 0000000..58004a3 --- /dev/null +++ b/packages/python-sdk/tests/e2b_connect/test_client.py @@ -0,0 +1,171 @@ +import asyncio + +import pytest + +from e2b_connect.client import ( + EnvelopeFlags, + ServerStreamParser, + _retry, + encode_envelope, +) + + +class GoodError(Exception): + pass + + +class BadError(Exception): + pass + + +def test_sync_retry_after_expected_exception(): + total = 0 + + @_retry(GoodError, 1) + def f(): + nonlocal total + total += 1 + raise GoodError() + + with pytest.raises(GoodError): + f() + + assert total == 2 + + +def test_sync_do_not_retry_on_unexpected_exception(): + total = 0 + + @_retry(GoodError, 1) + def f(): + nonlocal total + total += 1 + raise BadError() + + with pytest.raises(BadError): + f() + + assert total == 1 + + +def test_sync_do_not_throw_when_retry_works(): + total = 0 + + @_retry(GoodError, 1) + def f(): + nonlocal total + total += 1 + + if total < 2: + raise GoodError() + + return True + + result = f() + assert result is True + assert total == 2 + + +async def test_async_retry_after_expected_exception(): + total = 0 + + @_retry(GoodError, 1) + async def f(): + nonlocal total + total += 1 + raise GoodError() + + with pytest.raises(GoodError): + await f() + + assert total == 2 + + +async def test_async_do_not_retry_on_unexpected_exception(): + total = 0 + + @_retry(GoodError, 1) + async def f(): + nonlocal total + total += 1 + raise BadError() + + with pytest.raises(BadError): + await f() + + assert total == 1 + + +async def test_async_do_not_throw_when_retry_works(): + total = 0 + + @_retry(GoodError, 1) + async def f(): + nonlocal total + total += 1 + + if total < 2: + raise GoodError() + + return True + + result = await f() + assert result is True + assert total == 2 + + +async def test_async_with_multiple_await_calls(): + total = 0 + + async def a(): + await asyncio.sleep(0.001) + + @_retry(GoodError, 1) + async def f(): + nonlocal total + total += 1 + + await a() + + if total < 2: + raise GoodError() + + await a() + + return True + + result = await f() + assert result is True + assert total == 2 + + +def make_parser(): + return ServerStreamParser( + decode=lambda data, msg_type: data, + response_type=None, + ) + + +def test_parser_yields_messages_from_single_chunk(): + parser = make_parser() + envelope = encode_envelope(flags=EnvelopeFlags(0), data=b"abc") + + assert list(parser.parse(envelope)) == [b"abc"] + + +def test_parser_handles_payload_split_after_header(): + parser = make_parser() + envelope = encode_envelope(flags=EnvelopeFlags(0), data=b"abc") + + # The header plus one payload byte arrive first; the remaining payload + # (shorter than a header) arrives in a later chunk. + assert list(parser.parse(envelope[:6])) == [] + assert list(parser.parse(envelope[6:])) == [b"abc"] + + +def test_parser_handles_end_stream_payload_shorter_than_header(): + parser = make_parser() + envelope = encode_envelope(flags=EnvelopeFlags.end_stream, data=b"{}") + + assert list(parser.parse(envelope[:5])) == [] + assert list(parser.parse(envelope[5:])) == [] diff --git a/packages/python-sdk/tests/shared/git/conftest.py b/packages/python-sdk/tests/shared/git/conftest.py new file mode 100644 index 0000000..60a5e0b --- /dev/null +++ b/packages/python-sdk/tests/shared/git/conftest.py @@ -0,0 +1,74 @@ +import random +from uuid import uuid4 + +import pytest + +BASE_DIR = "/tmp/test-git" + + +@pytest.fixture +def git_sandbox(sandbox_factory): + return sandbox_factory(timeout=10) + + +@pytest.fixture +def git_author(): + return "Sandbox Bot", "sandbox@example.com" + + +@pytest.fixture +def git_credentials(): + return "git", "token", "example.com", "https" + + +@pytest.fixture +def git_base_dir(git_sandbox): + base_dir = f"{BASE_DIR}/{uuid4().hex}" + git_sandbox.commands.run(f'rm -rf "{base_dir}" && mkdir -p "{base_dir}"') + yield base_dir + git_sandbox.commands.run(f'rm -rf "{base_dir}"') + + +@pytest.fixture +def git_repo(git_sandbox, git_base_dir, git_author): + repo_path = f"{git_base_dir}/repo" + git_sandbox.git.init(repo_path, initial_branch="main") + author_name, author_email = git_author + git_sandbox.git.configure_user(author_name, author_email) + return repo_path + + +@pytest.fixture +def git_repo_with_commit(git_sandbox, git_repo, git_author): + author_name, author_email = git_author + git_sandbox.files.write(f"{git_repo}/README.md", "hello\n") + git_sandbox.git.add(git_repo, all=True) + git_sandbox.git.commit( + git_repo, + message="Initial commit", + author_name=author_name, + author_email=author_email, + ) + return git_repo + + +@pytest.fixture +def git_daemon(git_sandbox, git_base_dir): + remote_path = f"{git_base_dir}/remote.git" + git_sandbox.commands.run(f'git init --bare --initial-branch=main "{remote_path}"') + port = 9418 + random.randint(0, 1000) + cmd = ( + f'git daemon --reuseaddr --base-path="{git_base_dir}" --export-all ' + f"--enable=receive-pack --informative-errors --listen=127.0.0.1 --port={port}" + ) + handle = git_sandbox.commands.run(cmd, background=True) + git_sandbox.commands.run("sleep 1") + try: + yield { + "remote_path": remote_path, + "remote_url": f"git://127.0.0.1:{port}/remote.git", + "port": port, + "base_dir": git_base_dir, + } + finally: + handle.kill() diff --git a/packages/python-sdk/tests/shared/git/test_add.py b/packages/python-sdk/tests/shared/git/test_add.py new file mode 100644 index 0000000..5e549a3 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_add.py @@ -0,0 +1,16 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_add_stages_files(git_sandbox, git_repo): + git_sandbox.files.write(f"{git_repo}/README.md", "hello\n") + + git_sandbox.git.add(git_repo, all=True) + + status = git_sandbox.git.status(git_repo) + entry = next( + (item for item in status.file_status if item.name == "README.md"), None + ) + assert entry is not None + assert entry.status == "added" + assert entry.staged is True diff --git a/packages/python-sdk/tests/shared/git/test_args.py b/packages/python-sdk/tests/shared/git/test_args.py new file mode 100644 index 0000000..81fe8b7 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_args.py @@ -0,0 +1,31 @@ +import pytest + +from e2b.exceptions import InvalidArgumentException +from e2b.sandbox._git import ( + build_remote_add_args, + build_remote_get_command, + build_reset_args, +) + + +def test_build_reset_args_rejects_invalid_mode(): + with pytest.raises(InvalidArgumentException) as exc: + build_reset_args("bogus", None, None) + # Order must match the JS SDK exactly. + assert "Reset mode must be one of soft, mixed, hard, merge, keep." in str(exc.value) + + +def test_build_reset_args_accepts_valid_mode(): + assert build_reset_args("hard", "HEAD", None) == ["reset", "--hard", "HEAD"] + + +def test_build_remote_add_args_requires_name_and_url(): + with pytest.raises(InvalidArgumentException): + build_remote_add_args("", "https://example.com", False) + with pytest.raises(InvalidArgumentException): + build_remote_add_args("origin", "", False) + + +def test_build_remote_get_command_requires_name(): + with pytest.raises(InvalidArgumentException): + build_remote_get_command("/repo", "") diff --git a/packages/python-sdk/tests/shared/git/test_branches.py b/packages/python-sdk/tests/shared/git/test_branches.py new file mode 100644 index 0000000..c2385f1 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_branches.py @@ -0,0 +1,52 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_branches_lists_current_and_feature(git_sandbox, git_repo_with_commit): + repo_path = git_repo_with_commit + + git_sandbox.commands.run(f'git -C "{repo_path}" branch feature') + + branches = git_sandbox.git.branches(repo_path) + assert branches.current_branch == "main" + assert "main" in branches.branches + assert "feature" in branches.branches + + +@pytest.mark.skip_debug() +def test_checkout_branch_switches_branch(git_sandbox, git_repo_with_commit): + repo_path = git_repo_with_commit + + git_sandbox.commands.run(f'git -C "{repo_path}" branch feature') + git_sandbox.git.checkout_branch(repo_path, "feature") + + head = git_sandbox.commands.run( + f'git -C "{repo_path}" rev-parse --abbrev-ref HEAD' + ).stdout.strip() + assert head == "feature" + + +@pytest.mark.skip_debug() +def test_create_branch_creates_branch(git_sandbox, git_repo_with_commit): + repo_path = git_repo_with_commit + + git_sandbox.git.create_branch(repo_path, "feature") + + branches = git_sandbox.git.branches(repo_path) + assert "feature" in branches.branches + assert branches.current_branch == "feature" + + +@pytest.mark.skip_debug() +def test_delete_branch_removes_branch(git_sandbox, git_repo_with_commit): + repo_path = git_repo_with_commit + + git_sandbox.commands.run(f'git -C "{repo_path}" branch feature') + git_sandbox.git.delete_branch(repo_path, "feature") + + branch = git_sandbox.commands.run( + f'git -C "{repo_path}" branch --list feature' + ).stdout.strip() + branches = git_sandbox.git.branches(repo_path) + assert branch == "" + assert "feature" not in branches.branches diff --git a/packages/python-sdk/tests/shared/git/test_clone.py b/packages/python-sdk/tests/shared/git/test_clone.py new file mode 100644 index 0000000..9281319 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_clone.py @@ -0,0 +1,22 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_clone_fetches_repo( + git_sandbox, git_repo_with_commit, git_daemon, git_base_dir +): + repo_path = git_repo_with_commit + remote_url = git_daemon["remote_url"] + clone_path = f"{git_base_dir}/clone" + + git_sandbox.git.remote_add(repo_path, "origin", remote_url) + git_sandbox.git.push( + repo_path, + remote="origin", + branch="main", + set_upstream=True, + ) + + git_sandbox.git.clone(remote_url, clone_path) + contents = git_sandbox.files.read(f"{clone_path}/README.md") + assert "hello" in contents diff --git a/packages/python-sdk/tests/shared/git/test_commit.py b/packages/python-sdk/tests/shared/git/test_commit.py new file mode 100644 index 0000000..bead219 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_commit.py @@ -0,0 +1,46 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_commit_creates_commit(git_sandbox, git_repo, git_author): + git_sandbox.set_timeout(20) + + author_name, author_email = git_author + + git_sandbox.files.write(f"{git_repo}/README.md", "hello\n") + git_sandbox.git.add(git_repo, all=True) + git_sandbox.git.commit( + git_repo, + message="Initial commit", + author_name=author_name, + author_email=author_email, + ) + + message = git_sandbox.commands.run( + f'git -C "{git_repo}" log -1 --pretty=%B' + ).stdout.strip() + assert message == "Initial commit" + + +@pytest.mark.skip_debug() +def test_commit_uses_config_for_missing_author(git_sandbox, git_repo, git_author): + _, expected_email = git_author + + git_sandbox.files.write(f"{git_repo}/README.md", "hello\n") + git_sandbox.git.add(git_repo, all=True) + override_name = "Override Bot" + git_sandbox.git.commit( + git_repo, + message="Partial author commit", + author_name=override_name, + ) + + author_name = git_sandbox.commands.run( + f'git -C "{git_repo}" log -1 --pretty=%an' + ).stdout.strip() + logged_email = git_sandbox.commands.run( + f'git -C "{git_repo}" log -1 --pretty=%ae' + ).stdout.strip() + + assert author_name == override_name + assert logged_email == expected_email diff --git a/packages/python-sdk/tests/shared/git/test_config.py b/packages/python-sdk/tests/shared/git/test_config.py new file mode 100644 index 0000000..af52bf7 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_config.py @@ -0,0 +1,52 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_get_config_reads_local_config(git_sandbox, git_repo): + git_sandbox.commands.run(f'git -C "{git_repo}" config --local pull.rebase true') + + value = git_sandbox.git.get_config("pull.rebase", scope="local", path=git_repo) + command_value = git_sandbox.commands.run( + f'git -C "{git_repo}" config --local --get pull.rebase' + ).stdout.strip() + assert value == "true" + assert command_value == "true" + + +@pytest.mark.skip_debug() +def test_set_config_updates_local_config(git_sandbox, git_repo): + git_sandbox.git.set_config( + "pull.rebase", + "true", + scope="local", + path=git_repo, + ) + + value = git_sandbox.commands.run( + f'git -C "{git_repo}" config --local --get pull.rebase' + ).stdout.strip() + configured_value = git_sandbox.git.get_config( + "pull.rebase", scope="local", path=git_repo + ) + assert value == "true" + assert configured_value == "true" + + +@pytest.mark.skip_debug() +def test_configure_user_sets_global_config(git_sandbox, git_author): + author_name, author_email = git_author + + git_sandbox.git.configure_user(author_name, author_email) + + name = git_sandbox.commands.run( + "git config --global --get user.name" + ).stdout.strip() + email = git_sandbox.commands.run( + "git config --global --get user.email" + ).stdout.strip() + configured_name = git_sandbox.git.get_config("user.name", scope="global") + configured_email = git_sandbox.git.get_config("user.email", scope="global") + assert name == author_name + assert email == author_email + assert configured_name == author_name + assert configured_email == author_email diff --git a/packages/python-sdk/tests/shared/git/test_dangerously_authenticate.py b/packages/python-sdk/tests/shared/git/test_dangerously_authenticate.py new file mode 100644 index 0000000..67b44b4 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_dangerously_authenticate.py @@ -0,0 +1,20 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_dangerously_authenticate_sets_helper(git_sandbox, git_credentials): + username, password, host, protocol = git_credentials + + git_sandbox.git.dangerously_authenticate( + username, + password, + host=host, + protocol=protocol, + ) + + helper = git_sandbox.commands.run( + "git config --global --get credential.helper" + ).stdout.strip() + configured_helper = git_sandbox.git.get_config("credential.helper", scope="global") + assert helper == "store" + assert configured_helper == "store" diff --git a/packages/python-sdk/tests/shared/git/test_init.py b/packages/python-sdk/tests/shared/git/test_init.py new file mode 100644 index 0000000..19f5089 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_init.py @@ -0,0 +1,14 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_init_creates_repo(git_sandbox, git_base_dir): + repo_path = f"{git_base_dir}/repo" + + git_sandbox.git.init(repo_path, initial_branch="main") + + assert git_sandbox.files.exists(f"{repo_path}/.git") + head = git_sandbox.commands.run( + f'git -C "{repo_path}" symbolic-ref --short HEAD' + ).stdout.strip() + assert head == "main" diff --git a/packages/python-sdk/tests/shared/git/test_parity.py b/packages/python-sdk/tests/shared/git/test_parity.py new file mode 100644 index 0000000..c45b8f9 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_parity.py @@ -0,0 +1,33 @@ +import inspect + +from e2b.sandbox_async.git import Git as AsyncGit +from e2b.sandbox_sync.git import Git as SyncGit + + +def _public_methods(cls): + return { + name: getattr(cls, name) + for name in sorted(dir(cls)) + if not name.startswith("_") and callable(getattr(cls, name)) + } + + +def test_identical_method_signatures(): + sync = _public_methods(SyncGit) + async_ = _public_methods(AsyncGit) + + assert set(sync) == set(async_), ( + f"missing from async: {set(sync) - set(async_)}, " + f"missing from sync: {set(async_) - set(sync)}" + ) + + for name in sync: + assert inspect.signature(sync[name]) == inspect.signature(async_[name]), ( + f"{name}: sync{inspect.signature(sync[name])} " + f"!= async{inspect.signature(async_[name])}" + ) + + +def test_async_methods_are_coroutines(): + for name, method in _public_methods(AsyncGit).items(): + assert inspect.iscoroutinefunction(method), f"AsyncGit.{name} is not async" diff --git a/packages/python-sdk/tests/shared/git/test_remote.py b/packages/python-sdk/tests/shared/git/test_remote.py new file mode 100644 index 0000000..b66f104 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_remote.py @@ -0,0 +1,44 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_remote_get_returns_none_for_missing_remote(git_sandbox, git_repo): + repo_path = git_repo + missing_url = git_sandbox.git.remote_get(repo_path, "origin") + assert missing_url is None + + +@pytest.mark.skip_debug() +def test_remote_add_adds_remote(git_sandbox, git_repo, git_daemon): + repo_path = git_repo + remote_url = git_daemon["remote_url"] + + git_sandbox.git.remote_add(repo_path, "origin", remote_url) + current_url = git_sandbox.git.remote_get(repo_path, "origin") + assert current_url == remote_url + + +@pytest.mark.skip_debug() +def test_remote_add_overwrites_existing_remote(git_sandbox, git_repo, git_daemon): + repo_path = git_repo + remote_url = git_daemon["remote_url"] + + git_sandbox.git.remote_add(repo_path, "origin", remote_url) + current_url = git_sandbox.commands.run( + f'git -C "{repo_path}" remote get-url origin' + ).stdout.strip() + current_remote = git_sandbox.git.remote_get(repo_path, "origin") + assert current_url == remote_url + assert current_remote == remote_url + + second_path = f"{git_daemon['base_dir']}/remote-2.git" + git_sandbox.commands.run(f'git init --bare --initial-branch=main "{second_path}"') + second_url = f"git://127.0.0.1:{git_daemon['port']}/remote-2.git" + git_sandbox.git.remote_add(repo_path, "origin", second_url, overwrite=True) + + updated_url = git_sandbox.commands.run( + f'git -C "{repo_path}" remote get-url origin' + ).stdout.strip() + updated_remote = git_sandbox.git.remote_get(repo_path, "origin") + assert updated_url == second_url + assert updated_remote == second_url diff --git a/packages/python-sdk/tests/shared/git/test_reset.py b/packages/python-sdk/tests/shared/git/test_reset.py new file mode 100644 index 0000000..b216aea --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_reset.py @@ -0,0 +1,17 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_reset_hard_discards_changes(git_sandbox, git_repo_with_commit): + git_sandbox.files.write(f"{git_repo_with_commit}/README.md", "changed\n") + + status = git_sandbox.git.status(git_repo_with_commit) + assert status.is_clean is False + + git_sandbox.git.reset(git_repo_with_commit, mode="hard", target="HEAD") + + status_after = git_sandbox.git.status(git_repo_with_commit) + assert status_after.is_clean is True + + contents = git_sandbox.files.read(f"{git_repo_with_commit}/README.md") + assert contents == "hello\n" diff --git a/packages/python-sdk/tests/shared/git/test_restore.py b/packages/python-sdk/tests/shared/git/test_restore.py new file mode 100644 index 0000000..53dc7f2 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_restore.py @@ -0,0 +1,37 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_restore_unstages_changes(git_sandbox, git_repo_with_commit): + git_sandbox.files.write(f"{git_repo_with_commit}/README.md", "changed\n") + git_sandbox.git.add(git_repo_with_commit, files=["README.md"]) + + status = git_sandbox.git.status(git_repo_with_commit) + assert status.has_staged is True + + git_sandbox.git.restore( + git_repo_with_commit, + paths=["README.md"], + staged=True, + worktree=False, + ) + + status_after = git_sandbox.git.status(git_repo_with_commit) + assert status_after.has_staged is False + assert status_after.has_changes is True + + +@pytest.mark.skip_debug() +def test_restore_worktree_discards_changes(git_sandbox, git_repo_with_commit): + git_sandbox.files.write(f"{git_repo_with_commit}/README.md", "changed\n") + + status = git_sandbox.git.status(git_repo_with_commit) + assert status.is_clean is False + + git_sandbox.git.restore(git_repo_with_commit, paths=["README.md"]) + + status_after = git_sandbox.git.status(git_repo_with_commit) + assert status_after.is_clean is True + + contents = git_sandbox.files.read(f"{git_repo_with_commit}/README.md") + assert contents == "hello\n" diff --git a/packages/python-sdk/tests/shared/git/test_status.py b/packages/python-sdk/tests/shared/git/test_status.py new file mode 100644 index 0000000..5ac450d --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_status.py @@ -0,0 +1,88 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_status_reports_untracked_file(git_sandbox, git_repo): + git_sandbox.files.write(f"{git_repo}/README.md", "hello\n") + + status = git_sandbox.git.status(git_repo) + entry = next( + (item for item in status.file_status if item.name == "README.md"), None + ) + + assert entry is not None + assert entry.status == "untracked" + assert status.is_clean is False + assert status.has_changes is True + assert status.has_untracked is True + assert status.has_staged is False + assert status.has_conflicts is False + assert status.total_count == 1 + assert status.staged_count == 0 + assert status.unstaged_count == 1 + assert status.untracked_count == 1 + assert status.conflict_count == 0 + + +@pytest.mark.skip_debug() +def test_status_reports_added_modified_deleted_renamed( + git_sandbox, git_repo, git_author +): + author_name, author_email = git_author + + git_sandbox.files.write(f"{git_repo}/README.md", "hello\n") + git_sandbox.files.write(f"{git_repo}/DELETE.md", "delete me\n") + git_sandbox.files.write(f"{git_repo}/RENAME.md", "rename me\n") + git_sandbox.git.add(git_repo, all=True) + git_sandbox.git.commit( + git_repo, + message="Initial commit", + author_name=author_name, + author_email=author_email, + ) + + git_sandbox.files.write(f"{git_repo}/README.md", "hello again\n") + git_sandbox.files.write(f"{git_repo}/NEW.md", "new file\n") + git_sandbox.git.add(git_repo, files=["NEW.md"]) + git_sandbox.commands.run(f'git -C "{git_repo}" rm DELETE.md') + git_sandbox.commands.run(f'git -C "{git_repo}" mv RENAME.md RENAMED.md') + + status = git_sandbox.git.status(git_repo) + modified = next( + (item for item in status.file_status if item.name == "README.md"), None + ) + added = next((item for item in status.file_status if item.name == "NEW.md"), None) + deleted = next( + (item for item in status.file_status if item.name == "DELETE.md"), None + ) + renamed = next( + (item for item in status.file_status if item.name == "RENAMED.md"), + None, + ) + + assert modified is not None + assert modified.status == "modified" + assert modified.staged is False + + assert added is not None + assert added.status == "added" + assert added.staged is True + + assert deleted is not None + assert deleted.status == "deleted" + assert deleted.staged is True + + assert renamed is not None + assert renamed.status == "renamed" + assert renamed.staged is True + assert renamed.renamed_from == "RENAME.md" + + assert status.has_changes is True + assert status.has_staged is True + assert status.has_untracked is False + assert status.has_conflicts is False + assert status.total_count == 4 + assert status.staged_count == 3 + assert status.unstaged_count == 1 + assert status.untracked_count == 0 + assert status.conflict_count == 0 diff --git a/packages/python-sdk/tests/shared/git/test_sync.py b/packages/python-sdk/tests/shared/git/test_sync.py new file mode 100644 index 0000000..2cd591f --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_sync.py @@ -0,0 +1,81 @@ +import pytest + +from e2b.exceptions import GitUpstreamException + + +@pytest.mark.skip_debug() +def test_push_updates_remote(git_sandbox, git_repo_with_commit, git_daemon): + repo_path = git_repo_with_commit + remote_url = git_daemon["remote_url"] + + git_sandbox.git.remote_add(repo_path, "origin", remote_url) + git_sandbox.git.push( + repo_path, + remote="origin", + branch="main", + set_upstream=True, + ) + + message = git_sandbox.commands.run( + f'git --git-dir="{git_daemon["remote_path"]}" log -1 --pretty=%B' + ).stdout.strip() + assert message == "Initial commit" + + +@pytest.mark.skip_debug() +def test_push_without_upstream_warns(git_sandbox, git_repo_with_commit, git_daemon): + repo_path = git_repo_with_commit + remote_url = git_daemon["remote_url"] + + git_sandbox.git.remote_add(repo_path, "origin", remote_url) + + with pytest.raises(GitUpstreamException) as exc: + git_sandbox.git.push(repo_path, set_upstream=False) + + assert "no upstream branch is configured" in str(exc.value).lower() + + +@pytest.mark.skip_debug() +def test_pull_updates_clone( + git_sandbox, git_repo_with_commit, git_daemon, git_base_dir, git_author +): + repo_path = git_repo_with_commit + remote_url = git_daemon["remote_url"] + clone_path = f"{git_base_dir}/clone" + + git_sandbox.git.remote_add(repo_path, "origin", remote_url) + git_sandbox.git.push( + repo_path, + remote="origin", + branch="main", + set_upstream=True, + ) + git_sandbox.git.clone(remote_url, clone_path) + + git_sandbox.files.write(f"{repo_path}/README.md", "hello\nmore\n") + author_name, author_email = git_author + git_sandbox.git.add(repo_path, all=True) + git_sandbox.git.commit( + repo_path, + message="Update README", + author_name=author_name, + author_email=author_email, + ) + git_sandbox.git.push(repo_path) + + git_sandbox.git.pull(clone_path) + contents = git_sandbox.files.read(f"{clone_path}/README.md") + assert "more" in contents + + +@pytest.mark.skip_debug() +def test_pull_without_upstream_warns(git_sandbox, git_repo_with_commit, git_daemon): + repo_path = git_repo_with_commit + remote_url = git_daemon["remote_url"] + + git_sandbox.git.remote_add(repo_path, "origin", remote_url) + + with pytest.raises(GitUpstreamException) as exc: + git_sandbox.git.pull(repo_path) + + assert "no upstream branch is configured" in str(exc.value).lower() diff --git a/packages/python-sdk/tests/shared/template/test_readycmd.py b/packages/python-sdk/tests/shared/template/test_readycmd.py new file mode 100644 index 0000000..daf4304 --- /dev/null +++ b/packages/python-sdk/tests/shared/template/test_readycmd.py @@ -0,0 +1,48 @@ +from e2b.template.readycmd import ( + wait_for_file, + wait_for_port, + wait_for_process, + wait_for_timeout, + wait_for_url, +) + + +def test_wait_for_port_matches_the_exact_listening_port(): + cmd = wait_for_port(80).get_cmd() + assert cmd == '[ -n "$(ss -Htuln sport = :80)" ]' + + +def test_wait_for_url_quotes_the_url(): + cmd = wait_for_url("http://localhost:3000/health?ready=1&x=y").get_cmd() + assert cmd == ( + 'curl -s -o /dev/null -w "%{http_code}" ' + "'http://localhost:3000/health?ready=1&x=y' | grep -q \"200\"" + ) + + +def test_wait_for_url_keeps_simple_urls_unquoted(): + cmd = wait_for_url("http://localhost:3000/health").get_cmd() + assert cmd == ( + 'curl -s -o /dev/null -w "%{http_code}" ' + 'http://localhost:3000/health | grep -q "200"' + ) + + +def test_wait_for_process_quotes_the_process_name(): + cmd = wait_for_process("my daemon").get_cmd() + assert cmd == "pgrep 'my daemon' > /dev/null" + + +def test_wait_for_file_quotes_the_filename(): + cmd = wait_for_file("/tmp/ready file").get_cmd() + assert cmd == "[ -f '/tmp/ready file' ]" + + +def test_wait_for_file_keeps_simple_paths_unquoted(): + cmd = wait_for_file("/tmp/ready").get_cmd() + assert cmd == "[ -f /tmp/ready ]" + + +def test_wait_for_timeout_converts_milliseconds_to_seconds(): + assert wait_for_timeout(5000).get_cmd() == "sleep 5" + assert wait_for_timeout(100).get_cmd() == "sleep 1" diff --git a/packages/python-sdk/tests/shared/template/utils/get_all_files_in_path.py b/packages/python-sdk/tests/shared/template/utils/get_all_files_in_path.py new file mode 100644 index 0000000..fc1faa8 --- /dev/null +++ b/packages/python-sdk/tests/shared/template/utils/get_all_files_in_path.py @@ -0,0 +1,334 @@ +import os +import tempfile +import pytest +from e2b.template.utils import get_all_files_in_path + + +class TestGetAllFilesInPath: + @pytest.fixture + def test_dir(self): + """Create a temporary directory for testing.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield tmpdir + + def test_should_return_files_matching_simple_pattern(self, test_dir): + """Test that function returns files matching a simple pattern.""" + # Create test files + with open(os.path.join(test_dir, "file1.txt"), "w") as f: + f.write("content1") + with open(os.path.join(test_dir, "file2.txt"), "w") as f: + f.write("content2") + with open(os.path.join(test_dir, "file3.js"), "w") as f: + f.write("content3") + + files = get_all_files_in_path("*.txt", test_dir, []) + + assert len(files) == 2 + assert any("file1.txt" in f for f in files) + assert any("file2.txt" in f for f in files) + assert not any("file3.js" in f for f in files) + + def test_should_handle_directory_patterns_recursively(self, test_dir): + """Test that function handles directory patterns recursively.""" + # Create nested directory structure + os.makedirs(os.path.join(test_dir, "src", "components"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "src", "utils"), exist_ok=True) + + with open(os.path.join(test_dir, "src", "index.ts"), "w") as f: + f.write("index content") + with open(os.path.join(test_dir, "src", "components", "Button.tsx"), "w") as f: + f.write("button content") + with open(os.path.join(test_dir, "src", "utils", "helper.ts"), "w") as f: + f.write("helper content") + with open(os.path.join(test_dir, "README.md"), "w") as f: + f.write("readme content") + + files = get_all_files_in_path("src", test_dir, []) + + assert len(files) == 6 # 3 files + 3 directories (src, components, utils) + assert any("index.ts" in f for f in files) + assert any("Button.tsx" in f for f in files) + assert any("helper.ts" in f for f in files) + assert not any("README.md" in f for f in files) + + def test_should_respect_ignore_patterns(self, test_dir): + """Test that function respects ignore patterns.""" + # Create test files + with open(os.path.join(test_dir, "file1.txt"), "w") as f: + f.write("content1") + with open(os.path.join(test_dir, "file2.txt"), "w") as f: + f.write("content2") + with open(os.path.join(test_dir, "temp.txt"), "w") as f: + f.write("temp content") + with open(os.path.join(test_dir, "backup.txt"), "w") as f: + f.write("backup content") + + files = get_all_files_in_path("*.txt", test_dir, ["temp*", "backup*"]) + + assert len(files) == 2 + assert any("file1.txt" in f for f in files) + assert any("file2.txt" in f for f in files) + assert not any("temp.txt" in f for f in files) + assert not any("backup.txt" in f for f in files) + + def test_should_handle_complex_ignore_patterns(self, test_dir): + """Test that function handles complex ignore patterns.""" + # Create nested structure with various file types + os.makedirs(os.path.join(test_dir, "src", "components"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "src", "utils"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "tests"), exist_ok=True) + + with open(os.path.join(test_dir, "src", "index.ts"), "w") as f: + f.write("index content") + with open(os.path.join(test_dir, "src", "components", "Button.tsx"), "w") as f: + f.write("button content") + with open(os.path.join(test_dir, "src", "utils", "helper.ts"), "w") as f: + f.write("helper content") + with open(os.path.join(test_dir, "tests", "test.spec.ts"), "w") as f: + f.write("test content") + with open( + os.path.join(test_dir, "src", "components", "Button.test.tsx"), "w" + ) as f: + f.write("test content") + with open(os.path.join(test_dir, "src", "utils", "helper.spec.ts"), "w") as f: + f.write("spec content") + + files = get_all_files_in_path("src", test_dir, ["**/*.test.*", "**/*.spec.*"]) + + assert len(files) == 6 # 3 files + 3 directories (src, components, utils) + assert any("index.ts" in f for f in files) + assert any("Button.tsx" in f for f in files) + assert any("helper.ts" in f for f in files) + assert not any("Button.test.tsx" in f for f in files) + assert not any("helper.spec.ts" in f for f in files) + + def test_should_handle_empty_directories(self, test_dir): + """Test that function handles empty directories.""" + os.makedirs(os.path.join(test_dir, "empty"), exist_ok=True) + with open(os.path.join(test_dir, "file.txt"), "w") as f: + f.write("content") + + files = get_all_files_in_path("empty", test_dir, []) + + assert len(files) == 1 + + def test_should_handle_mixed_files_and_directories(self, test_dir): + """Test that function handles mixed files and directories.""" + # Create a mix of files and directories + with open(os.path.join(test_dir, "file1.txt"), "w") as f: + f.write("content1") + os.makedirs(os.path.join(test_dir, "dir1"), exist_ok=True) + with open(os.path.join(test_dir, "dir1", "file2.txt"), "w") as f: + f.write("content2") + with open(os.path.join(test_dir, "file3.txt"), "w") as f: + f.write("content3") + + files = get_all_files_in_path("*", test_dir, []) + + assert len(files) == 4 + assert any("file1.txt" in f for f in files) + assert any("file2.txt" in f for f in files) + assert any("file3.txt" in f for f in files) + + def test_should_handle_glob_patterns_with_subdirectories(self, test_dir): + """Test that function handles glob patterns with subdirectories.""" + # Create nested structure + os.makedirs(os.path.join(test_dir, "src", "components"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "src", "utils"), exist_ok=True) + + with open(os.path.join(test_dir, "src", "index.ts"), "w") as f: + f.write("index content") + with open(os.path.join(test_dir, "src", "components", "Button.tsx"), "w") as f: + f.write("button content") + with open(os.path.join(test_dir, "src", "utils", "helper.ts"), "w") as f: + f.write("helper content") + with open(os.path.join(test_dir, "src", "components", "Button.css"), "w") as f: + f.write("css content") + + files = get_all_files_in_path("src/**/*", test_dir, []) + + assert len(files) == 6 + assert any("index.ts" in f for f in files) + assert any("Button.tsx" in f for f in files) + assert any("helper.ts" in f for f in files) + assert any("Button.css" in f for f in files) + + def test_should_handle_specific_file_extensions(self, test_dir): + """Test that function handles specific file extensions.""" + with open(os.path.join(test_dir, "file1.ts"), "w") as f: + f.write("ts content") + with open(os.path.join(test_dir, "file2.js"), "w") as f: + f.write("js content") + with open(os.path.join(test_dir, "file3.tsx"), "w") as f: + f.write("tsx content") + with open(os.path.join(test_dir, "file4.css"), "w") as f: + f.write("css content") + + files = get_all_files_in_path("*.ts", test_dir, []) + + assert len(files) == 1 + assert any("file1.ts" in f for f in files) + + def test_should_return_sorted_files(self, test_dir): + """Test that function returns sorted files.""" + with open(os.path.join(test_dir, "zebra.txt"), "w") as f: + f.write("z content") + with open(os.path.join(test_dir, "apple.txt"), "w") as f: + f.write("a content") + with open(os.path.join(test_dir, "banana.txt"), "w") as f: + f.write("b content") + + files = get_all_files_in_path("*.txt", test_dir, []) + + assert len(files) == 3 + assert "apple.txt" in files[0] + assert "banana.txt" in files[1] + assert "zebra.txt" in files[2] + + def test_should_handle_no_matching_files(self, test_dir): + """Test that function handles no matching files.""" + with open(os.path.join(test_dir, "file.txt"), "w") as f: + f.write("content") + + files = get_all_files_in_path("*.js", test_dir, []) + + assert len(files) == 0 + + def test_should_handle_complex_ignore_patterns_with_directories(self, test_dir): + """Test that function handles complex ignore patterns with directories.""" + # Create a complex structure + os.makedirs(os.path.join(test_dir, "src", "components"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "src", "utils"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "src", "tests"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "dist"), exist_ok=True) + + with open(os.path.join(test_dir, "src", "index.ts"), "w") as f: + f.write("index content") + with open(os.path.join(test_dir, "src", "components", "Button.tsx"), "w") as f: + f.write("button content") + with open(os.path.join(test_dir, "src", "utils", "helper.ts"), "w") as f: + f.write("helper content") + with open(os.path.join(test_dir, "src", "tests", "test.spec.ts"), "w") as f: + f.write("test content") + with open(os.path.join(test_dir, "dist", "bundle.js"), "w") as f: + f.write("bundle content") + with open(os.path.join(test_dir, "README.md"), "w") as f: + f.write("readme content") + + files = get_all_files_in_path("src", test_dir, ["**/tests/**", "**/*.spec.*"]) + + assert len(files) == 6 # 3 files + 3 directories (src, components, utils) + assert any("index.ts" in f for f in files) + assert any("Button.tsx" in f for f in files) + assert any("helper.ts" in f for f in files) + assert not any("test.spec.ts" in f for f in files) + + def test_should_include_dotfiles(self, test_dir): + """Test that function includes files starting with a dot.""" + with open(os.path.join(test_dir, "file.txt"), "w") as f: + f.write("content") + with open(os.path.join(test_dir, ".env"), "w") as f: + f.write("SECRET=123") + with open(os.path.join(test_dir, ".gitignore"), "w") as f: + f.write("node_modules") + + files = get_all_files_in_path("*", test_dir, []) + + assert len(files) == 3 + assert any(".env" in f for f in files) + assert any(".gitignore" in f for f in files) + assert any("file.txt" in f for f in files) + + def test_should_include_dotfiles_in_subdirectories(self, test_dir): + """Test that function includes dotfiles inside subdirectories.""" + os.makedirs(os.path.join(test_dir, "src"), exist_ok=True) + with open(os.path.join(test_dir, "src", "index.ts"), "w") as f: + f.write("content") + with open(os.path.join(test_dir, "src", ".env.local"), "w") as f: + f.write("SECRET=123") + + files = get_all_files_in_path("src", test_dir, []) + + assert any("index.ts" in f for f in files) + assert any(".env.local" in f for f in files) + + def test_should_include_dotdirectories_and_their_contents(self, test_dir): + """Test that function includes dot-prefixed directories and their contents.""" + os.makedirs(os.path.join(test_dir, ".hidden"), exist_ok=True) + with open(os.path.join(test_dir, ".hidden", "config.json"), "w") as f: + f.write("{}") + with open(os.path.join(test_dir, "visible.txt"), "w") as f: + f.write("content") + + files = get_all_files_in_path("*", test_dir, []) + + assert any(".hidden" in f for f in files) + assert any("config.json" in f for f in files) + assert any("visible.txt" in f for f in files) + + def test_should_respect_ignore_patterns_for_dotfiles(self, test_dir): + """Test that dotfiles can be excluded via ignore patterns.""" + with open(os.path.join(test_dir, ".env"), "w") as f: + f.write("SECRET=123") + with open(os.path.join(test_dir, ".gitignore"), "w") as f: + f.write("node_modules") + with open(os.path.join(test_dir, "file.txt"), "w") as f: + f.write("content") + + files = get_all_files_in_path("*", test_dir, [".env"]) + + assert len(files) == 2 + assert not any(f.endswith(".env") for f in files) + assert any(".gitignore" in f for f in files) + assert any("file.txt" in f for f in files) + + def test_should_handle_symlinks(self, test_dir): + """Test that function handles symbolic links.""" + # Create a file and a symlink to it + with open(os.path.join(test_dir, "original.txt"), "w") as f: + f.write("original content") + + # Create symlink (only on Unix-like systems) + if hasattr(os, "symlink"): + os.symlink("original.txt", os.path.join(test_dir, "link.txt")) + + files = get_all_files_in_path("*.txt", test_dir, []) + + assert len(files) == 2 + assert any("original.txt" in f for f in files) + assert any("link.txt" in f for f in files) + + def test_should_handle_nested_ignore_patterns(self, test_dir): + """Test that function handles nested ignore patterns.""" + # Create nested structure + os.makedirs(os.path.join(test_dir, "src", "components", "ui"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "src", "components", "forms"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "src", "utils"), exist_ok=True) + + with open(os.path.join(test_dir, "src", "index.ts"), "w") as f: + f.write("index content") + with open( + os.path.join(test_dir, "src", "components", "ui", "Button.tsx"), "w" + ) as f: + f.write("button content") + with open( + os.path.join(test_dir, "src", "components", "forms", "Input.tsx"), "w" + ) as f: + f.write("input content") + with open(os.path.join(test_dir, "src", "utils", "helper.ts"), "w") as f: + f.write("helper content") + with open( + os.path.join(test_dir, "src", "components", "ui", "Button.test.tsx"), "w" + ) as f: + f.write("test content") + + files = get_all_files_in_path("src", test_dir, ["**/ui/**"]) + + assert ( + len(files) == 7 + ) # 3 files + 4 directories (src, components, forms, utils) + assert any("index.ts" in f for f in files) + assert any("Input.tsx" in f for f in files) + assert any("helper.ts" in f for f in files) + assert not any("Button.tsx" in f for f in files) + assert not any("Button.test.tsx" in f for f in files) diff --git a/packages/python-sdk/tests/shared/template/utils/test_get_caller_directory.py b/packages/python-sdk/tests/shared/template/utils/test_get_caller_directory.py new file mode 100644 index 0000000..ba31413 --- /dev/null +++ b/packages/python-sdk/tests/shared/template/utils/test_get_caller_directory.py @@ -0,0 +1,6 @@ +import os +from e2b.template.utils import get_caller_directory + + +def test_get_caller_directory(): + assert get_caller_directory(1) == os.path.dirname(__file__) diff --git a/packages/python-sdk/tests/shared/template/utils/test_normalize_build_arguments.py b/packages/python-sdk/tests/shared/template/utils/test_normalize_build_arguments.py new file mode 100644 index 0000000..f6e83c4 --- /dev/null +++ b/packages/python-sdk/tests/shared/template/utils/test_normalize_build_arguments.py @@ -0,0 +1,45 @@ +import pytest + +from e2b.template.utils import normalize_build_arguments +from e2b.exceptions import TemplateException + + +def test_handles_string_name(): + result = normalize_build_arguments(name="my-template:v1.0") + assert result == "my-template:v1.0" + + +def test_handles_name_without_tag(): + result = normalize_build_arguments(name="my-template") + assert result == "my-template" + + +def test_handles_legacy_alias(): + result = normalize_build_arguments(alias="my-template") + assert result == "my-template" + + +def test_name_takes_precedence_over_alias(): + # When both are provided, name should be used + result = normalize_build_arguments(name="from-name", alias="from-alias") + assert result == "from-name" + + +def test_throws_for_empty_name(): + with pytest.raises(TemplateException, match="Name must be provided"): + normalize_build_arguments(name="") + + +def test_throws_for_empty_alias(): + with pytest.raises(TemplateException, match="Name must be provided"): + normalize_build_arguments(alias="") + + +def test_throws_for_missing_name_and_alias(): + with pytest.raises(TemplateException, match="Name must be provided"): + normalize_build_arguments() + + +def test_throws_for_none_values(): + with pytest.raises(TemplateException, match="Name must be provided"): + normalize_build_arguments(name=None, alias=None) diff --git a/packages/python-sdk/tests/shared/template/utils/test_strip_ansi_escape_codes.py b/packages/python-sdk/tests/shared/template/utils/test_strip_ansi_escape_codes.py new file mode 100644 index 0000000..d0874ad --- /dev/null +++ b/packages/python-sdk/tests/shared/template/utils/test_strip_ansi_escape_codes.py @@ -0,0 +1,29 @@ +from e2b.template.utils import strip_ansi_escape_codes + + +def test_strips_basic_sgr_color(): + assert strip_ansi_escape_codes("\x1b[31mred\x1b[0m") == "red" + + +def test_strips_semicolon_separated_params(): + assert strip_ansi_escape_codes("\x1b[1;31;42mhi\x1b[0m") == "hi" + + +def test_strips_semicolon_256_color(): + assert strip_ansi_escape_codes("\x1b[38;5;82mX\x1b[0m") == "X" + + +def test_strips_colon_256_color(): + assert strip_ansi_escape_codes("\x1b[38:5:82mX\x1b[0m") == "X" + + +def test_strips_colon_truecolor(): + assert strip_ansi_escape_codes("\x1b[38:2::255:0:0mRED\x1b[0m") == "RED" + + +def test_strips_colon_curly_underline(): + assert strip_ansi_escape_codes("\x1b[4:3mX\x1b[0m") == "X" + + +def test_leaves_plain_text_unchanged(): + assert strip_ansi_escape_codes("no escape codes here") == "no escape codes here" diff --git a/packages/python-sdk/tests/shared/template/utils/test_tar_file_stream.py b/packages/python-sdk/tests/shared/template/utils/test_tar_file_stream.py new file mode 100644 index 0000000..9f6b4fd --- /dev/null +++ b/packages/python-sdk/tests/shared/template/utils/test_tar_file_stream.py @@ -0,0 +1,172 @@ +import os +import tempfile +import tarfile +import pytest +from typing import IO +from e2b.template.utils import tar_file_stream + + +class TestTarFileStream: + @pytest.fixture + def test_dir(self): + """Create a temporary directory for testing.""" + tmpdir = tempfile.TemporaryDirectory() + yield tmpdir.name + tmpdir.cleanup() + + def _extract_tar_contents(self, tar_buffer: IO[bytes]) -> dict: + """Extract tar contents into a dictionary mapping paths to file contents.""" + tar_buffer.seek(0) + contents = {} + # "r:*" auto-detects compressed vs. uncompressed archives + with tarfile.open(fileobj=tar_buffer, mode="r:*") as tar: + for member in tar.getmembers(): + if member.isfile(): + file_obj = tar.extractfile(member) + if file_obj: + contents[member.name] = file_obj.read() + elif member.isdir(): + contents[member.name] = None # Mark as directory + return contents + + def test_should_create_tar_with_simple_files(self, test_dir): + """Test that function creates tar with simple files.""" + # Create test files + file1_path = os.path.join(test_dir, "file1.txt") + file2_path = os.path.join(test_dir, "file2.txt") + + with open(file1_path, "w") as f: + f.write("content1") + with open(file2_path, "w") as f: + f.write("content2") + + tar_buffer = tar_file_stream("*.txt", test_dir, [], False, True) + contents = self._extract_tar_contents(tar_buffer) + + assert len(contents) == 2 + assert "file1.txt" in contents + assert "file2.txt" in contents + assert contents["file1.txt"] == b"content1" + assert contents["file2.txt"] == b"content2" + + def test_should_create_uncompressed_tar_when_gzip_disabled(self, test_dir): + """Test that function creates an uncompressed tar when gzip=False.""" + file1_path = os.path.join(test_dir, "file1.txt") + file2_path = os.path.join(test_dir, "file2.txt") + + with open(file1_path, "w") as f: + f.write("content1") + with open(file2_path, "w") as f: + f.write("content2") + + tar_buffer = tar_file_stream("*.txt", test_dir, [], False, False) + + # gzip streams start with the magic bytes 0x1f 0x8b — a plain tar must not + tar_buffer.seek(0) + assert tar_buffer.read(2) != b"\x1f\x8b" + + # The archive must still be readable and contain the original files + contents = self._extract_tar_contents(tar_buffer) + assert contents["file1.txt"] == b"content1" + assert contents["file2.txt"] == b"content2" + + def test_should_respect_ignore_patterns(self, test_dir): + """Test that function respects ignore patterns.""" + # Create test files + with open(os.path.join(test_dir, "file1.txt"), "w") as f: + f.write("content1") + with open(os.path.join(test_dir, "file2.txt"), "w") as f: + f.write("content2") + with open(os.path.join(test_dir, "temp.txt"), "w") as f: + f.write("temp content") + with open(os.path.join(test_dir, "backup.txt"), "w") as f: + f.write("backup content") + + tar_buffer = tar_file_stream( + "*.txt", test_dir, ["temp*", "backup*"], False, True + ) + contents = self._extract_tar_contents(tar_buffer) + + assert len(contents) == 2 + assert "file1.txt" in contents + assert "file2.txt" in contents + assert contents["file1.txt"] == b"content1" + assert contents["file2.txt"] == b"content2" + assert "temp.txt" not in contents + assert "backup.txt" not in contents + + def test_should_handle_nested_files(self, test_dir): + """Test that function handles nested directory structures.""" + # Create nested directory structure + nested_dir = os.path.join(test_dir, "src", "components") + os.makedirs(nested_dir, exist_ok=True) + + with open(os.path.join(test_dir, "src", "index.ts"), "w") as f: + f.write("index content") + with open(os.path.join(nested_dir, "Button.tsx"), "w") as f: + f.write("button content") + + tar_buffer = tar_file_stream("src", test_dir, [], False, True) + contents = self._extract_tar_contents(tar_buffer) + + # Should include the directory and files + assert "src/index.ts" in contents + assert "src/components/Button.tsx" in contents + + def test_should_resolve_symlinks_when_enabled(self, test_dir): + """Test that function resolves symlinks when resolve_symlinks=True.""" + if not hasattr(os, "symlink"): + pytest.skip("Symlinks not supported on this platform") + + # Create original file + original_path = os.path.join(test_dir, "original.txt") + with open(original_path, "w") as f: + f.write("original content") + + # Create symlink + symlink_path = os.path.join(test_dir, "link.txt") + os.symlink("original.txt", symlink_path) + + # Test with resolve_symlinks=True + tar_buffer = tar_file_stream("*.txt", test_dir, [], True, True) + contents = self._extract_tar_contents(tar_buffer) + + # Both files should be in tar + assert "original.txt" in contents + assert "link.txt" in contents + # Symlink should be resolved (contain actual content, not link) + assert contents["original.txt"] == b"original content" + assert contents["link.txt"] == b"original content" + + def test_should_preserve_symlinks_when_disabled(self, test_dir): + """Test that function preserves symlinks when resolve_symlinks=False.""" + if not hasattr(os, "symlink"): + pytest.skip("Symlinks not supported on this platform") + + # Create original file + original_path = os.path.join(test_dir, "original.txt") + with open(original_path, "w") as f: + f.write("original content") + + # Create symlink + symlink_path = os.path.join(test_dir, "link.txt") + os.symlink("original.txt", symlink_path) + + # Test with resolve_symlinks=False + tar_buffer = tar_file_stream("*.txt", test_dir, [], False, True) + tar_buffer.seek(0) + + with tarfile.open(fileobj=tar_buffer, mode="r:gz") as tar: + members = {m.name: m for m in tar.getmembers()} + + # Both files should be in tar + assert "original.txt" in members + assert "link.txt" in members + + # Original should be a regular file + assert members["original.txt"].isfile() + assert not members["original.txt"].issym() + + # Link should be a symlink + assert members["link.txt"].issym() + assert members["link.txt"].linkname == "original.txt" diff --git a/packages/python-sdk/tests/shared/template/utils/test_validate_relative_path.py b/packages/python-sdk/tests/shared/template/utils/test_validate_relative_path.py new file mode 100644 index 0000000..0fbbb43 --- /dev/null +++ b/packages/python-sdk/tests/shared/template/utils/test_validate_relative_path.py @@ -0,0 +1,106 @@ +import sys +import pytest + +from e2b.template.utils import validate_relative_path +from e2b.exceptions import TemplateException + +is_windows = sys.platform == "win32" + + +class TestValidateRelativePathValid: + """Test cases for valid paths.""" + + def test_accepts_simple_relative_path(self): + validate_relative_path("foo", None) + + def test_accepts_nested_relative_path(self): + validate_relative_path("foo/bar", None) + + def test_accepts_path_with_dot_prefix(self): + validate_relative_path("./foo", None) + + def test_accepts_nested_path_with_dot_prefix(self): + validate_relative_path("./foo/bar", None) + + def test_accepts_internal_parent_ref_within_context(self): + validate_relative_path("foo/../bar", None) + + def test_accepts_current_directory(self): + validate_relative_path(".", None) + + def test_accepts_glob_patterns(self): + validate_relative_path("*.txt", None) + validate_relative_path("**/*.ts", None) + validate_relative_path("src/**/*", None) + + def test_accepts_filenames_starting_with_double_dots(self): + validate_relative_path("..myconfig", None) + validate_relative_path("..cache", None) + validate_relative_path("...something", None) + validate_relative_path("foo/..myconfig", None) + + +class TestValidateRelativePathInvalidAbsolute: + """Test cases for invalid absolute paths.""" + + def test_rejects_unix_absolute_path(self): + with pytest.raises(TemplateException) as excinfo: + validate_relative_path("/absolute/path", None) + assert "absolute paths are not allowed" in str(excinfo.value) + + def test_rejects_root_path(self): + with pytest.raises(TemplateException): + validate_relative_path("/", None) + + @pytest.mark.skipif(not is_windows, reason="Windows path test only runs on Windows") + def test_rejects_windows_drive_letter_path(self): + with pytest.raises(TemplateException) as excinfo: + validate_relative_path("C:\\Windows\\System32", None) + assert "absolute paths are not allowed" in str(excinfo.value) + + +class TestValidateRelativePathInvalidEscape: + """Test cases for paths that escape the context directory.""" + + def test_rejects_simple_parent_directory_escape(self): + with pytest.raises(TemplateException) as excinfo: + validate_relative_path("../foo", None) + assert "path escapes the context directory" in str(excinfo.value) + + def test_rejects_double_parent_directory_escape(self): + with pytest.raises(TemplateException): + validate_relative_path("../../foo", None) + + def test_rejects_nested_parent_refs_escape(self): + with pytest.raises(TemplateException): + validate_relative_path("foo/../../bar", None) + + def test_rejects_dot_prefix_escape(self): + with pytest.raises(TemplateException): + validate_relative_path("./foo/../../../bar", None) + + def test_rejects_just_parent_directory(self): + with pytest.raises(TemplateException): + validate_relative_path("..", None) + + def test_rejects_current_directory_followed_by_parent(self): + with pytest.raises(TemplateException): + validate_relative_path("./..", None) + + def test_rejects_deeply_nested_escape(self): + with pytest.raises(TemplateException): + validate_relative_path("a/b/c/../../../../escape", None) + + +class TestValidateRelativePathErrorMessages: + """Test cases for error message content.""" + + def test_absolute_path_error_includes_path(self): + with pytest.raises(TemplateException) as excinfo: + validate_relative_path("/etc/passwd", None) + assert "/etc/passwd" in str(excinfo.value) + + def test_escape_path_error_includes_path(self): + with pytest.raises(TemplateException) as excinfo: + validate_relative_path("../secret", None) + assert "../secret" in str(excinfo.value) diff --git a/packages/python-sdk/tests/sync/api_sync/test_sbx_info.py b/packages/python-sdk/tests/sync/api_sync/test_sbx_info.py new file mode 100644 index 0000000..e9b362e --- /dev/null +++ b/packages/python-sdk/tests/sync/api_sync/test_sbx_info.py @@ -0,0 +1,9 @@ +import pytest + +from e2b import Sandbox + + +@pytest.mark.skip_debug() +def test_get_info(sandbox: Sandbox): + info = Sandbox.get_info(sandbox.sandbox_id) + assert info.sandbox_id == sandbox.sandbox_id diff --git a/packages/python-sdk/tests/sync/api_sync/test_sbx_kill.py b/packages/python-sdk/tests/sync/api_sync/test_sbx_kill.py new file mode 100644 index 0000000..56275ca --- /dev/null +++ b/packages/python-sdk/tests/sync/api_sync/test_sbx_kill.py @@ -0,0 +1,21 @@ +import pytest + +from e2b import Sandbox, SandboxQuery, SandboxState + + +@pytest.mark.skip_debug() +def test_kill_existing_sandbox(sandbox: Sandbox, sandbox_test_id: str): + assert Sandbox.kill(sandbox.sandbox_id) + + paginator = Sandbox.list( + query=SandboxQuery( + state=[SandboxState.RUNNING], metadata={"sandbox_test_id": sandbox_test_id} + ) + ) + sandboxes = paginator.next_items() + assert sandbox.sandbox_id not in [s.sandbox_id for s in sandboxes] + + +@pytest.mark.skip_debug() +def test_kill_non_existing_sandbox(): + assert not Sandbox.kill("nonexistingsandbox") diff --git a/packages/python-sdk/tests/sync/api_sync/test_sbx_list.py b/packages/python-sdk/tests/sync/api_sync/test_sbx_list.py new file mode 100644 index 0000000..c139ccb --- /dev/null +++ b/packages/python-sdk/tests/sync/api_sync/test_sbx_list.py @@ -0,0 +1,189 @@ +import uuid + +import pytest + +from e2b import Sandbox, SandboxQuery, SandboxState + + +@pytest.mark.skip_debug() +def test_list_sandboxes(sandbox: Sandbox, sandbox_test_id: str): + paginator = Sandbox.list( + query=SandboxQuery(metadata={"sandbox_test_id": sandbox_test_id}) + ) + sandboxes = paginator.next_items() + assert len(sandboxes) >= 1 + assert sandbox.sandbox_id in [sbx.sandbox_id for sbx in sandboxes] + + +@pytest.mark.skip_debug() +def test_list_sandboxes_with_filter(sandbox_factory, sandbox_test_id: str): + unique_id = str(uuid.uuid4()) + extra_sbx = sandbox_factory(metadata={"unique_id": unique_id}) + + paginator = Sandbox.list(query=SandboxQuery(metadata={"unique_id": unique_id})) + sandboxes = paginator.next_items() + assert len(sandboxes) == 1 + assert sandboxes[0].sandbox_id == extra_sbx.sandbox_id + + +@pytest.mark.skip_debug() +def test_list_running_sandboxes(sandbox: Sandbox, sandbox_test_id: str): + paginator = Sandbox.list( + query=SandboxQuery( + metadata={"sandbox_test_id": sandbox_test_id}, state=[SandboxState.RUNNING] + ) + ) + sandboxes = paginator.next_items() + assert len(sandboxes) >= 1 + + # Verify our running sandbox is in the list + assert any( + s.sandbox_id == sandbox.sandbox_id and s.state == SandboxState.RUNNING + for s in sandboxes + ) + + +@pytest.mark.skip_debug() +def test_list_paused_sandboxes(sandbox: Sandbox, sandbox_test_id: str): + sandbox.beta_pause() + + paginator = Sandbox.list( + query=SandboxQuery( + metadata={"sandbox_test_id": sandbox_test_id}, state=[SandboxState.PAUSED] + ) + ) + sandboxes = paginator.next_items() + assert len(sandboxes) >= 1 + + # Verify our paused sandbox is in the list + assert any( + s.sandbox_id == sandbox.sandbox_id and s.state == SandboxState.PAUSED + for s in sandboxes + ) + + +@pytest.mark.skip_debug() +def test_paginate_running_sandboxes( + sandbox: Sandbox, sandbox_factory, sandbox_test_id: str +): + # Create two sandboxes + extra_sbx = sandbox_factory(metadata={"sandbox_test_id": sandbox_test_id}) + + # Test pagination with limit + paginator = Sandbox.list( + query=SandboxQuery( + metadata={"sandbox_test_id": sandbox_test_id}, + state=[SandboxState.RUNNING], + ), + limit=1, + ) + + sandboxes = paginator.next_items() + + # Check first page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.RUNNING + assert paginator.has_next is True + assert paginator.next_token is not None + assert sandboxes[0].sandbox_id == extra_sbx.sandbox_id + + # Get second page + sandboxes = paginator.next_items() + + # Check second page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.RUNNING + assert paginator.has_next is False + assert paginator.next_token is None + assert sandboxes[0].sandbox_id == sandbox.sandbox_id + + +@pytest.mark.skip_debug() +def test_paginate_paused_sandboxes( + sandbox: Sandbox, sandbox_factory, sandbox_test_id: str +): + sandbox.beta_pause() + + # create another paused sandbox + extra_sbx = sandbox_factory(metadata={"sandbox_test_id": sandbox_test_id}) + extra_sbx.beta_pause() + + # Test pagination with limit + paginator = Sandbox.list( + query=SandboxQuery( + state=[SandboxState.PAUSED], + metadata={"sandbox_test_id": sandbox_test_id}, + ), + limit=1, + ) + + sandboxes = paginator.next_items() + + # Check first page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.PAUSED + assert paginator.has_next is True + assert paginator.next_token is not None + assert sandboxes[0].sandbox_id == extra_sbx.sandbox_id + + # Get second page + sandboxes = paginator.next_items() + + # Check second page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.PAUSED + assert paginator.has_next is False + assert paginator.next_token is None + assert sandboxes[0].sandbox_id == sandbox.sandbox_id + + +@pytest.mark.skip_debug() +def test_paginate_running_and_paused_sandboxes( + sandbox: Sandbox, sandbox_factory, sandbox_test_id: str +): + # Create extra paused sandbox + extra_sbx = sandbox_factory(metadata={"sandbox_test_id": sandbox_test_id}) + extra_sbx.beta_pause() + + # Test pagination with limit + paginator = Sandbox.list( + query=SandboxQuery( + metadata={"sandbox_test_id": sandbox_test_id}, + state=[SandboxState.RUNNING, SandboxState.PAUSED], + ), + limit=1, + ) + + sandboxes = paginator.next_items() + + # Check first page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.PAUSED + assert paginator.has_next is True + assert paginator.next_token is not None + assert sandboxes[0].sandbox_id == extra_sbx.sandbox_id + + # Get second page + sandboxes = paginator.next_items() + + # Check second page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.RUNNING + assert paginator.has_next is False + assert paginator.next_token is None + assert sandboxes[0].sandbox_id == sandbox.sandbox_id + + +@pytest.mark.skip_debug() +def test_paginate_iterator(sandbox: Sandbox, sandbox_test_id: str): + paginator = Sandbox.list( + query=SandboxQuery(metadata={"sandbox_test_id": sandbox_test_id}) + ) + sandboxes_list = [] + + while paginator.has_next: + sandboxes = paginator.next_items() + sandboxes_list.extend(sandboxes) + + assert len(sandboxes_list) > 0 + assert sandbox.sandbox_id in [sbx.sandbox_id for sbx in sandboxes_list] diff --git a/packages/python-sdk/tests/sync/api_sync/test_sbx_snapshot.py b/packages/python-sdk/tests/sync/api_sync/test_sbx_snapshot.py new file mode 100644 index 0000000..627a2f6 --- /dev/null +++ b/packages/python-sdk/tests/sync/api_sync/test_sbx_snapshot.py @@ -0,0 +1,19 @@ +import pytest +from e2b import Sandbox + + +@pytest.mark.skip_debug() +def test_pause_sandbox(sandbox: Sandbox): + Sandbox.pause(sandbox.sandbox_id) + assert not sandbox.is_running() + + +@pytest.mark.skip_debug() +def test_resume_sandbox(sandbox: Sandbox): + # pause + Sandbox.pause(sandbox.sandbox_id) + assert not sandbox.is_running() + + # resume + Sandbox.connect(sandbox.sandbox_id) + assert sandbox.is_running() diff --git a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_cmd_connect.py b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_cmd_connect.py new file mode 100644 index 0000000..eb70569 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_cmd_connect.py @@ -0,0 +1,18 @@ +import pytest + +from e2b import NotFoundException + + +def test_connect_to_process(sandbox): + cmd = sandbox.commands.run("sleep 10", background=True) + pid = cmd.pid + + process_info = sandbox.commands.connect(pid) + assert process_info.pid == pid + + +def test_connect_to_non_existing_process(sandbox): + non_existing_pid = 999999 + + with pytest.raises(NotFoundException): + sandbox.commands.connect(non_existing_pid) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_cmd_kill.py b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_cmd_kill.py new file mode 100644 index 0000000..45e7ca2 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_cmd_kill.py @@ -0,0 +1,19 @@ +import pytest + +from e2b import Sandbox, CommandExitException + + +def test_kill_process(sandbox: Sandbox): + cmd = sandbox.commands.run("sleep 10", background=True) + pid = cmd.pid + + sandbox.commands.kill(pid) + + with pytest.raises(CommandExitException): + sandbox.commands.run(f"kill -0 {pid}") + + +def test_kill_non_existing_process(sandbox): + non_existing_pid = 999999 + + assert not sandbox.commands.kill(non_existing_pid) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_cmd_list.py b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_cmd_list.py new file mode 100644 index 0000000..30bedb5 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_cmd_list.py @@ -0,0 +1,13 @@ +from e2b import Sandbox + + +def test_kill_process(sandbox: Sandbox): + c1 = sandbox.commands.run("sleep 10", background=True) + c2 = sandbox.commands.run("sleep 10", background=True) + + processes = sandbox.commands.list() + + assert len(processes) >= 2 + pids = [p.pid for p in processes] + assert c1.pid in pids + assert c2.pid in pids diff --git a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_env_vars.py b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_env_vars.py new file mode 100644 index 0000000..a9752de --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_env_vars.py @@ -0,0 +1,35 @@ +import pytest + +from e2b import Sandbox + + +def test_command_envs(sandbox: Sandbox): + cmd = sandbox.commands.run("echo $FOO", envs={"FOO": "bar"}) + assert cmd.stdout.strip() == "bar" + + +@pytest.mark.skip_debug() +def test_sandbox_envs(sandbox_factory): + sbx = sandbox_factory(envs={"FOO": "bar"}) + + cmd = sbx.commands.run("echo $FOO") + assert cmd.stdout.strip() == "bar" + + +def test_bash_command_scoped_env_vars(sandbox: Sandbox): + cmd = sandbox.commands.run("echo $FOO", envs={"FOO": "bar"}) + assert cmd.exit_code == 0 + assert cmd.stdout.strip() == "bar" + + # test that it is secure and not accessible to subsequent commands + cmd2 = sandbox.commands.run('sudo echo "$FOO"') + assert cmd2.exit_code == 0 + assert cmd2.stdout.strip() == "" + + +def test_python_command_scoped_env_vars(sandbox: Sandbox): + cmd = sandbox.commands.run( + "python3 -c \"import os; print(os.environ['FOO'])\"", envs={"FOO": "bar"} + ) + assert cmd.exit_code == 0 + assert cmd.stdout.strip() == "bar" diff --git a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py new file mode 100644 index 0000000..e75e5ba --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py @@ -0,0 +1,58 @@ +import pytest + +from e2b import Sandbox, TimeoutException + + +def test_run(sandbox: Sandbox): + text = "Hello, World!" + + cmd = sandbox.commands.run(f'echo "{text}"') + + assert cmd.exit_code == 0 + assert cmd.stdout == f"{text}\n" + + +def test_run_with_special_characters(sandbox: Sandbox): + text = "!@#$%^&*()_+" + + cmd = sandbox.commands.run(f'echo "{text}"') + + assert cmd.exit_code == 0 + assert cmd.stdout == f"{text}\n" + + +def test_run_with_broken_utf8(sandbox: Sandbox): + # Create a string with 8191 'a' characters followed by the problematic byte 0xe2 + long_str = "a" * 8191 + "\\xe2" + result = sandbox.commands.run(f'printf "{long_str}"') + assert result.exit_code == 0 + + # The broken UTF-8 bytes should be replaced with the Unicode replacement character + assert result.stdout == ("a" * 8191 + "\ufffd") + + +def test_run_with_multiline_string(sandbox): + text = "Hello,\nWorld!" + + cmd = sandbox.commands.run(f'echo "{text}"') + + assert cmd.exit_code == 0 + assert cmd.stdout == f"{text}\n" + + +def test_run_with_timeout(sandbox): + cmd = sandbox.commands.run('echo "Hello, World!"', timeout=10) + + assert cmd.exit_code == 0 + + +def test_run_with_too_short_timeout(sandbox): + with pytest.raises(TimeoutException): + sandbox.commands.run("sleep 10", timeout=2) + + +def test_run_with_too_short_timeout_iterating(sandbox): + cmd = sandbox.commands.run("sleep 10", timeout=2, background=True) + with pytest.raises(TimeoutException): + for _ in cmd: + pass diff --git a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_sandbox_killed_during_run.py b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_sandbox_killed_during_run.py new file mode 100644 index 0000000..81dd1cb --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_sandbox_killed_during_run.py @@ -0,0 +1,16 @@ +import pytest + +from e2b import Sandbox, TimeoutException + + +@pytest.mark.skip_debug() +def test_kill_sandbox_while_command_is_running(sandbox: Sandbox): + cmd = sandbox.commands.run("sleep 60", background=True) + + sandbox.kill() + + with pytest.raises(TimeoutException) as exc_info: + cmd.wait() + + # The health check confirms the sandbox is gone, so the error states it outright + assert "sandbox was killed or reached its end of life" in str(exc_info.value) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_send_stdin.py b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_send_stdin.py new file mode 100644 index 0000000..db5a323 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_send_stdin.py @@ -0,0 +1,57 @@ +from e2b import Sandbox + + +def test_send_stdin_to_process(sandbox: Sandbox): + cmd = sandbox.commands.run("cat", background=True, stdin=True) + sandbox.commands.send_stdin(cmd.pid, "Hello, World!") + + for stdout, _, _ in cmd: + assert stdout == "Hello, World!" + break + + +def test_send_bytes_stdin_to_process(sandbox: Sandbox): + cmd = sandbox.commands.run("cat", background=True, stdin=True) + sandbox.commands.send_stdin(cmd.pid, b"Hello, World!") + + for stdout, _, _ in cmd: + assert stdout == "Hello, World!" + break + + +def test_send_stdin_via_command_handle(sandbox: Sandbox): + cmd = sandbox.commands.run("cat", background=True, stdin=True) + cmd.send_stdin("Hello, World!") + + for stdout, _, _ in cmd: + assert stdout == "Hello, World!" + break + + +def test_close_stdin_via_command_handle(sandbox: Sandbox): + cmd = sandbox.commands.run("cat", background=True, stdin=True) + cmd.send_stdin("Hello, World!") + cmd.close_stdin() + + # `cat` exits once stdin is closed (EOF). + result = cmd.wait() + assert result.exit_code == 0 + assert result.stdout == "Hello, World!" + + +def test_send_special_characters_to_process(sandbox: Sandbox): + cmd = sandbox.commands.run("cat", background=True, stdin=True) + sandbox.commands.send_stdin(cmd.pid, "!@#$%^&*()_+") + + for stdout, _, _ in cmd: + assert stdout == "!@#$%^&*()_+" + break + + +def test_send_multiline_string_to_process(sandbox: Sandbox): + cmd = sandbox.commands.run("cat", background=True, stdin=True) + sandbox.commands.send_stdin(cmd.pid, "Hello,\nWorld!") + + for stdout, _, _ in cmd: + assert stdout == "Hello,\nWorld!" + break diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_content_encoding.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_content_encoding.py new file mode 100644 index 0000000..3d8b68b --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_content_encoding.py @@ -0,0 +1,61 @@ +from e2b.sandbox.filesystem.filesystem import WriteEntry + + +def test_write_and_read_with_gzip(sandbox, debug): + filename = "test_gzip_write.txt" + content = "This is a test file with gzip encoding." + + info = sandbox.files.write(filename, content, gzip=True) + assert info.path == f"/home/user/{filename}" + + read_content = sandbox.files.read(filename, gzip=True) + assert read_content == content + + if debug: + sandbox.files.remove(filename) + + +def test_write_gzip_read_plain(sandbox, debug): + filename = "test_gzip_write_plain_read.txt" + content = "Written with gzip, read without." + + sandbox.files.write(filename, content, gzip=True) + + read_content = sandbox.files.read(filename) + assert read_content == content + + if debug: + sandbox.files.remove(filename) + + +def test_write_files_with_gzip(sandbox, debug): + files = [ + WriteEntry(path="gzip_multi_1.txt", data="File 1 content"), + WriteEntry(path="gzip_multi_2.txt", data="File 2 content"), + WriteEntry(path="gzip_multi_3.txt", data="File 3 content"), + ] + + infos = sandbox.files.write_files(files, gzip=True) + assert len(infos) == len(files) + + for i, file in enumerate(files): + read_content = sandbox.files.read(file["path"]) + assert read_content == file["data"] + + if debug: + for file in files: + sandbox.files.remove(file["path"]) + + +def test_read_bytes_with_gzip(sandbox, debug): + filename = "test_gzip_bytes.txt" + content = "Binary content with gzip." + + sandbox.files.write(filename, content) + + read_bytes = sandbox.files.read(filename, format="bytes", gzip=True) + assert isinstance(read_bytes, bytearray) + assert read_bytes.decode("utf-8") == content + + if debug: + sandbox.files.remove(filename) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_exists.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_exists.py new file mode 100644 index 0000000..2a04aec --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_exists.py @@ -0,0 +1,8 @@ +from e2b import Sandbox + + +def test_exists(sandbox: Sandbox): + filename = "test_exists.txt" + + sandbox.files.write(filename, "test") + assert sandbox.files.exists(filename) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_files_list.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_files_list.py new file mode 100644 index 0000000..a3622c2 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_files_list.py @@ -0,0 +1,247 @@ +import uuid +from typing import Any + +from e2b import Sandbox, FileType + + +def test_list_directory(sandbox: Sandbox): + home_dir_name = "/home/user" + parent_dir_name = f"test_directory_{uuid.uuid4()}" + + sandbox.files.make_dir(parent_dir_name) + sandbox.files.make_dir(f"{parent_dir_name}/subdir1") + sandbox.files.make_dir(f"{parent_dir_name}/subdir2") + sandbox.files.make_dir(f"{parent_dir_name}/subdir1/subdir1_1") + sandbox.files.make_dir(f"{parent_dir_name}/subdir1/subdir1_2") + sandbox.files.make_dir(f"{parent_dir_name}/subdir2/subdir2_1") + sandbox.files.make_dir(f"{parent_dir_name}/subdir2/subdir2_2") + sandbox.files.write(f"{parent_dir_name}/file1.txt", "Hello, world!") + + test_cases: list[dict[str, Any]] = [ + { + "name": "default depth (1)", + "depth": None, + "expected_len": 3, + "expected_file_names": [ + "file1.txt", + "subdir1", + "subdir2", + ], + "expected_file_types": [ + FileType.FILE, + FileType.DIR, + FileType.DIR, + ], + "expected_file_paths": [ + f"{home_dir_name}/{parent_dir_name}/file1.txt", + f"{home_dir_name}/{parent_dir_name}/subdir1", + f"{home_dir_name}/{parent_dir_name}/subdir2", + ], + }, + { + "name": "explicit depth 1", + "depth": 1, + "expected_len": 3, + "expected_file_names": [ + "file1.txt", + "subdir1", + "subdir2", + ], + "expected_file_types": [ + FileType.FILE, + FileType.DIR, + FileType.DIR, + ], + "expected_file_paths": [ + f"{home_dir_name}/{parent_dir_name}/file1.txt", + f"{home_dir_name}/{parent_dir_name}/subdir1", + f"{home_dir_name}/{parent_dir_name}/subdir2", + ], + }, + { + "name": "explicit depth 2", + "depth": 2, + "expected_len": 7, + "expected_file_types": [ + FileType.FILE, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + ], + "expected_file_names": [ + "file1.txt", + "subdir1", + "subdir1_1", + "subdir1_2", + "subdir2", + "subdir2_1", + "subdir2_2", + ], + "expected_file_paths": [ + f"{home_dir_name}/{parent_dir_name}/file1.txt", + f"{home_dir_name}/{parent_dir_name}/subdir1", + f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_1", + f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_2", + f"{home_dir_name}/{parent_dir_name}/subdir2", + f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_1", + f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_2", + ], + }, + { + "name": "explicit depth 3 (should be the same as depth 2)", + "depth": 3, + "expected_len": 7, + "expected_file_names": [ + "file1.txt", + "subdir1", + "subdir1_1", + "subdir1_2", + "subdir2", + "subdir2_1", + "subdir2_2", + ], + "expected_file_types": [ + FileType.FILE, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + ], + "expected_file_paths": [ + f"{home_dir_name}/{parent_dir_name}/file1.txt", + f"{home_dir_name}/{parent_dir_name}/subdir1", + f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_1", + f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_2", + f"{home_dir_name}/{parent_dir_name}/subdir2", + f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_1", + f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_2", + ], + }, + ] + + for test_case in test_cases: + files = sandbox.files.list( + parent_dir_name, + depth=test_case["depth"] if test_case["depth"] is not None else None, + ) + + assert len(files) == test_case["expected_len"] + + for i in range(len(test_case["expected_file_names"])): + assert files[i].name == test_case["expected_file_names"][i] + assert files[i].path == test_case["expected_file_paths"][i] + assert files[i].type == test_case["expected_file_types"][i] + + sandbox.files.remove(parent_dir_name) + + +def test_list_directory_error_cases(sandbox: Sandbox): + parent_dir_name = f"test_directory_{uuid.uuid4()}" + sandbox.files.make_dir(parent_dir_name) + + expected_error_message = "depth should be at least 1" + try: + sandbox.files.list(parent_dir_name, depth=-1) + assert False, "Expected error but none was thrown" + except Exception as err: + assert expected_error_message in str(err), ( + f'expected error message to include "{expected_error_message}"' + ) + + sandbox.files.remove(parent_dir_name) + + +def test_file_entry_details(sandbox: Sandbox): + test_dir = "test-file-entry" + file_path = f"{test_dir}/test.txt" + content = "Hello, World!" + + sandbox.files.make_dir(test_dir) + sandbox.files.write(file_path, content) + + files = sandbox.files.list(test_dir, depth=1) + assert len(files) == 1 + + file_entry = files[0] + assert file_entry.name == "test.txt" + assert file_entry.path == f"/home/user/{file_path}" + assert file_entry.type == FileType.FILE + assert file_entry.mode == 0o644 + assert file_entry.permissions == "-rw-r--r--" + assert file_entry.owner == "user" + assert file_entry.group == "user" + assert file_entry.size == len(content) + assert file_entry.modified_time is not None + assert file_entry.symlink_target is None + + sandbox.files.remove(test_dir) + + +def test_directory_entry_details(sandbox: Sandbox): + test_dir = "test-entry-info" + sub_dir = f"{test_dir}/subdir" + + sandbox.files.make_dir(test_dir) + sandbox.files.make_dir(sub_dir) + + files = sandbox.files.list(test_dir, depth=1) + assert len(files) == 1 + + dir_entry = files[0] + assert dir_entry.name == "subdir" + assert dir_entry.path == f"/home/user/{sub_dir}" + assert dir_entry.type == FileType.DIR + assert dir_entry.mode == 0o755 + assert dir_entry.permissions == "drwxr-xr-x" + assert dir_entry.owner == "user" + assert dir_entry.group == "user" + assert dir_entry.modified_time is not None + + sandbox.files.remove(test_dir) + + +def test_mixed_entries(sandbox: Sandbox): + test_dir = "test-mixed-entries" + sub_dir = f"{test_dir}/subdir" + file_path = f"{test_dir}/test.txt" + content = "Hello, World!" + + sandbox.files.make_dir(test_dir) + sandbox.files.make_dir(sub_dir) + sandbox.files.write(file_path, content) + + files = sandbox.files.list(test_dir, depth=1) + assert len(files) == 2 + + # Create a dictionary of entries by name for easier verification + entries = {entry.name: entry for entry in files} + + # Verify directory entry + dir_entry = entries.get("subdir") + assert dir_entry is not None + assert dir_entry.path == f"/home/user/{sub_dir}" + assert dir_entry.type == FileType.DIR + assert dir_entry.mode == 0o755 + assert dir_entry.permissions == "drwxr-xr-x" + assert dir_entry.owner == "user" + assert dir_entry.group == "user" + assert dir_entry.modified_time is not None + + # Verify file entry + file_entry = entries.get("test.txt") + assert file_entry is not None + assert file_entry.path == f"/home/user/{file_path}" + assert file_entry.type == FileType.FILE + assert file_entry.mode == 0o644 + assert file_entry.permissions == "-rw-r--r--" + assert file_entry.owner == "user" + assert file_entry.group == "user" + assert file_entry.size == len(content) + assert file_entry.modified_time is not None + + sandbox.files.remove(test_dir) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_info.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_info.py new file mode 100644 index 0000000..c25ae24 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_info.py @@ -0,0 +1,75 @@ +import pytest +from e2b.exceptions import FileNotFoundException +from e2b import Sandbox, FileType + + +def test_get_info_of_file(sandbox: Sandbox): + filename = "test_file.txt" + + sandbox.files.write(filename, "test") + info = sandbox.files.get_info(filename) + current_path = sandbox.commands.run("pwd") + + assert info.name == filename + assert info.type == FileType.FILE + assert info.path == f"{current_path.stdout.strip()}/{filename}" + assert info.size == 4 + assert info.mode == 0o644 + assert info.permissions == "-rw-r--r--" + assert info.owner == "user" + assert info.group == "user" + assert info.modified_time is not None + assert info.modified_time.tzinfo is not None + + +def test_get_info_of_nonexistent_file(sandbox: Sandbox): + filename = "test_does_not_exist.txt" + + with pytest.raises(FileNotFoundException): + sandbox.files.get_info(filename) + + +def test_get_info_of_directory(sandbox: Sandbox): + dirname = "test_dir" + + sandbox.files.make_dir(dirname) + info = sandbox.files.get_info(dirname) + current_path = sandbox.commands.run("pwd") + + assert info.name == dirname + assert info.type == FileType.DIR + assert info.path == f"{current_path.stdout.strip()}/{dirname}" + assert info.size > 0 + assert info.mode == 0o755 + assert info.permissions == "drwxr-xr-x" + assert info.owner == "user" + assert info.group == "user" + assert info.modified_time is not None + assert info.modified_time.tzinfo is not None + + +def test_get_info_of_nonexistent_directory(sandbox: Sandbox): + dirname = "test_does_not_exist_dir" + + with pytest.raises(FileNotFoundException): + sandbox.files.get_info(dirname) + + +def test_file_symlink(sandbox: Sandbox): + test_dir = "test-simlink-entry" + file_name = "test.txt" + content = "Hello, World!" + + sandbox.files.make_dir(test_dir) + sandbox.files.write(f"{test_dir}/{file_name}", content) + + symlink_name = "symlink_to_test.txt" + sandbox.commands.run(f"ln -s {file_name} {symlink_name}", cwd=test_dir) + + file = sandbox.files.get_info(f"{test_dir}/{symlink_name}") + + pwd = sandbox.commands.run("pwd") + assert file.type == FileType.FILE + assert file.symlink_target == f"{pwd.stdout.strip()}/{test_dir}/{file_name}" + + sandbox.files.remove(test_dir) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_make_dir.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_make_dir.py new file mode 100644 index 0000000..9251e3d --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_make_dir.py @@ -0,0 +1,29 @@ +import uuid + +from e2b import Sandbox + + +def test_make_directory(sandbox: Sandbox): + dir_name = f"test_directory_{uuid.uuid4()}" + + sandbox.files.make_dir(dir_name) + exists = sandbox.files.exists(dir_name) + assert exists + + +async def test_make_directory_already_exists(sandbox: Sandbox): + dir_name = f"test_directory_{uuid.uuid4()}" + + created = sandbox.files.make_dir(dir_name) + assert created + + created = sandbox.files.make_dir(dir_name) + assert not created + + +def test_make_nested_directory(sandbox: Sandbox): + nested_dir_name = f"test_directory_{uuid.uuid4()}/nested_directory" + + sandbox.files.make_dir(nested_dir_name) + exists = sandbox.files.exists(nested_dir_name) + assert exists diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py new file mode 100644 index 0000000..6ed6d8e --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_metadata.py @@ -0,0 +1,163 @@ +import pytest + +from e2b.exceptions import InvalidArgumentException + + +def test_write_file_with_metadata(sandbox, debug): + filename = "test_metadata.txt" + content = "This is a test file with metadata." + metadata = {"author": "mish", "purpose": "upload"} + + info = sandbox.files.write(filename, content, metadata=metadata) + assert info.metadata == metadata + + # Metadata is persisted and surfaced on subsequent reads. + stat = sandbox.files.get_info(filename) + assert stat.metadata == metadata + + if debug: + sandbox.files.remove(filename) + + +def test_write_file_with_metadata_octet_stream(sandbox, debug): + filename = "test_metadata_octet.txt" + content = "This is a test file with metadata." + metadata = {"author": "mish", "purpose": "upload"} + + info = sandbox.files.write( + filename, content, metadata=metadata, use_octet_stream=True + ) + assert info.metadata == metadata + + stat = sandbox.files.get_info(filename) + assert stat.metadata == metadata + + if debug: + sandbox.files.remove(filename) + + +def test_write_file_without_metadata(sandbox, debug): + filename = "test_no_metadata.txt" + + info = sandbox.files.write(filename, "no metadata here") + assert info.metadata is None + + stat = sandbox.files.get_info(filename) + assert stat.metadata is None + + if debug: + sandbox.files.remove(filename) + + +def test_write_files_applies_metadata_to_every_file(sandbox, debug): + from e2b.sandbox.filesystem.filesystem import WriteEntry + + # The same metadata is applied to every file in the upload. + metadata = {"source": "test-suite"} + files = [ + WriteEntry(path="metadata_multi_1.txt", data="File 1"), + WriteEntry(path="metadata_multi_2.txt", data="File 2"), + ] + + infos = sandbox.files.write_files(files, metadata=metadata) + assert len(infos) == len(files) + + for info in infos: + assert info.metadata == metadata + stat = sandbox.files.get_info(info.path) + assert stat.metadata == metadata + + if debug: + for file in files: + sandbox.files.remove(file["path"]) + + +def test_metadata_surfaced_when_listing(sandbox, debug): + dirname = "metadata_list_dir" + filename = "listed.txt" + metadata = {"tag": "listed"} + + sandbox.files.make_dir(dirname) + sandbox.files.write(f"{dirname}/{filename}", "content", metadata=metadata) + + entries = sandbox.files.list(dirname) + entry = next((e for e in entries if e.name == filename), None) + assert entry is not None + assert entry.metadata == metadata + + if debug: + sandbox.files.remove(dirname) + + +def test_metadata_surfaced_after_rename(sandbox, debug): + old_path = "metadata_rename_old.txt" + new_path = "metadata_rename_new.txt" + metadata = {"stage": "renamed"} + + sandbox.files.write(old_path, "content", metadata=metadata) + info = sandbox.files.rename(old_path, new_path) + assert info.metadata == metadata + + if debug: + sandbox.files.remove(new_path) + + +def test_overwriting_clears_stale_metadata(sandbox, debug): + filename = "metadata_overwrite.txt" + + sandbox.files.write(filename, "first", metadata={"author": "mish"}) + + # Overwriting without metadata removes the previously stored metadata. + info = sandbox.files.write(filename, "second") + assert info.metadata is None + + stat = sandbox.files.get_info(filename) + assert stat.metadata is None + + if debug: + sandbox.files.remove(filename) + + +def test_metadata_set_via_xattrs_surfaced_in_get_info(sandbox, debug): + filename = "metadata_xattr.txt" + sandbox.files.write(filename, "content") + + file_path = sandbox.commands.run(f"realpath {filename}").stdout.strip() + + # Set an xattr directly in the `user.e2b.` namespace (out-of-band, not via + # the SDK upload); it should surface as metadata (with the namespace prefix + # stripped) when reading the file info. + sandbox.commands.run( + f"python3 -c \"import os; os.setxattr('{file_path}', 'user.e2b.author', b'mish')\"" + ) + + info = sandbox.files.get_info(filename) + assert info.metadata == {"author": "mish"} + + if debug: + sandbox.files.remove(filename) + + +def test_write_rejects_invalid_metadata(sandbox): + filename = "invalid_metadata.txt" + + # Key with a space is not a valid HTTP header token. + with pytest.raises(InvalidArgumentException): + sandbox.files.write(filename, "x", metadata={"bad key": "value"}) + + # Empty key. + with pytest.raises(InvalidArgumentException): + sandbox.files.write(filename, "x", metadata={"": "value"}) + + # Value with a non-printable / non-ASCII character. + with pytest.raises(InvalidArgumentException): + sandbox.files.write(filename, "x", metadata={"good": "bad\nvalue"}) + + # Trailing newline (Python's `$` would accept it; `\Z` must not). + with pytest.raises(InvalidArgumentException): + sandbox.files.write(filename, "x", metadata={"good": "value\n"}) + with pytest.raises(InvalidArgumentException): + sandbox.files.write(filename, "x", metadata={"key\n": "value"}) + + # The file must not have been created by a rejected write. + assert not sandbox.files.exists(filename) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_read.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_read.py new file mode 100644 index 0000000..4464ce9 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_read.py @@ -0,0 +1,83 @@ +import pytest +from e2b import FileNotFoundException, NotFoundException + + +def test_read_file(sandbox): + filename = "test_read.txt" + content = "Hello, world!" + + sandbox.files.write(filename, content) + read_content = sandbox.files.read(filename) + assert read_content == content + + +def test_read_non_existing_file(sandbox): + filename = "non_existing_file.txt" + + with pytest.raises(FileNotFoundException): + sandbox.files.read(filename) + + +def test_read_non_existing_file_catches_with_deprecated_not_found_exception(sandbox): + filename = "non_existing_file.txt" + + with pytest.raises(NotFoundException): + sandbox.files.read(filename) + + +def test_read_empty_file(sandbox): + filename = "empty_file.txt" + content = "" + + sandbox.commands.run(f"touch {filename}") + read_content = sandbox.files.read(filename) + assert read_content == content + + +def test_read_file_as_stream(sandbox): + filename = "test_read_stream.txt" + content = "Streamed read content. " * 10_000 + + sandbox.files.write(filename, content) + stream = sandbox.files.read(filename, format="stream") + read_content = b"".join(stream).decode("utf-8") + assert read_content == content + + +def test_read_file_as_stream_with_gzip(sandbox): + filename = "test_read_stream_gzip.txt" + content = "Streamed gzipped read content. " * 10_000 + + sandbox.files.write(filename, content) + stream = sandbox.files.read(filename, format="stream", gzip=True) + read_content = b"".join(stream).decode("utf-8") + assert read_content == content + + +def test_read_non_existing_file_as_stream(sandbox): + filename = "non_existing_file.txt" + + with pytest.raises(FileNotFoundException): + sandbox.files.read(filename, format="stream") + + +def test_read_file_as_stream_context_manager(sandbox): + filename = "test_read_stream_ctx.txt" + content = "Streamed read content. " * 10_000 + + sandbox.files.write(filename, content) + with sandbox.files.read(filename, format="stream") as stream: + read_content = b"".join(stream).decode("utf-8") + assert read_content == content + + +def test_read_file_as_stream_partial_then_close(sandbox): + filename = "test_read_stream_partial.txt" + content = "Streamed read content. " * 10_000 + + sandbox.files.write(filename, content) + # Reading only the first chunk and closing must not raise or leak. + stream = sandbox.files.read(filename, format="stream") + first = next(iter(stream)) + assert len(first) > 0 + stream.close() diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_remove.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_remove.py new file mode 100644 index 0000000..17e07cf --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_remove.py @@ -0,0 +1,18 @@ +from e2b import Sandbox + + +def test_remove_file(sandbox: Sandbox): + filename = "test_remove.txt" + content = "This file will be removed." + + sandbox.files.write(filename, content) + + sandbox.files.remove(filename) + + exists = sandbox.files.exists(filename) + assert not exists + + +def test_remove_non_existing_file(sandbox): + filename = "non_existing_file.txt" + sandbox.files.remove(filename) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_rename.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_rename.py new file mode 100644 index 0000000..f534cb4 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_rename.py @@ -0,0 +1,28 @@ +import pytest +from e2b import FileNotFoundException, Sandbox + + +def test_rename_file(sandbox: Sandbox): + old_filename = "test_rename_old.txt" + new_filename = "test_rename_new.txt" + content = "This file will be renamed." + + sandbox.files.write(old_filename, content) + + info = sandbox.files.rename(old_filename, new_filename) + assert info.path == f"/home/user/{new_filename}" + + exists_old = sandbox.files.exists(old_filename) + exists_new = sandbox.files.exists(new_filename) + assert not exists_old + assert exists_new + read_content = sandbox.files.read(new_filename) + assert read_content == content + + +def test_rename_non_existing_file(sandbox): + old_filename = "non_existing_file.txt" + new_filename = "new_non_existing_file.txt" + + with pytest.raises(FileNotFoundException): + sandbox.files.rename(old_filename, new_filename) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_secured.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_secured.py new file mode 100644 index 0000000..4fa5969 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_secured.py @@ -0,0 +1,59 @@ +import urllib.request +import urllib.error +import json +import pytest + + +@pytest.mark.skip_debug() +def test_download_url_with_signing(sandbox_factory): + sbx = sandbox_factory(timeout=100, secure=True) + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + sbx.files.write(file_path, file_content) + signed_url = sbx.download_url(file_path, "user") + + with urllib.request.urlopen(signed_url) as resp: + assert resp.status == 200 + body_bytes = resp.read() + body_text = body_bytes.decode() + assert body_text == file_content + + +@pytest.mark.skip_debug() +def test_download_url_with_signing_and_expiration(sandbox_factory): + sbx = sandbox_factory(timeout=100, secure=True) + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + sbx.files.write(file_path, file_content) + signed_url = sbx.download_url(file_path, "user", 120) + + with urllib.request.urlopen(signed_url) as resp: + assert resp.status == 200 + body_bytes = resp.read() + body_text = body_bytes.decode() + assert body_text == file_content + + +@pytest.mark.skip_debug() +def test_download_url_with_expired_signing(sandbox_factory): + sbx = sandbox_factory(timeout=100, secure=True) + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + sbx.files.write(file_path, file_content) + + signed_url = sbx.download_url(file_path, "user", use_signature_expiration=-120) + + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(signed_url) + + err = exc_info.value + assert err.code == 401, f"Unexpected status {err.code}" + + error_json_str = err.read().decode() # bytes ➜ str + error_payload = json.loads(error_json_str) # str ➜ dict + + expected_payload = {"code": 401, "message": "signature is already expired"} + assert error_payload == expected_payload diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_watch.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_watch.py new file mode 100644 index 0000000..9159070 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_watch.py @@ -0,0 +1,192 @@ +import pytest + +from e2b import ( + FileNotFoundException, + FileType, + FilesystemEventType, + Sandbox, + SandboxException, +) + + +def test_watch_directory_changes_with_entry_info(sandbox: Sandbox): + dirname = "test_watch_dir_entry" + filename = "test_watch.txt" + content = "This file will be watched." + new_content = "This file has been modified." + + sandbox.files.make_dir(dirname) + sandbox.files.write(f"{dirname}/{filename}", content) + + handle = sandbox.files.watch_dir(dirname, include_entry=True) + sandbox.files.write(f"{dirname}/{filename}", new_content) + + events = handle.get_new_events() + write_event = None + for event in events: + if event.type == FilesystemEventType.WRITE and event.name == filename: + write_event = event + break + + assert write_event is not None, ( + f"Expected WRITE event for {filename}, but got events: {events}" + ) + # The entry is populated best-effort for events where the path still exists. + assert write_event.entry is not None + assert write_event.entry.name == filename + assert write_event.entry.path == f"/home/user/{dirname}/{filename}" + assert write_event.entry.type == FileType.FILE + + handle.stop() + + +def test_watch_directory_changes_with_network_mounts_allowed(sandbox: Sandbox): + dirname = "test_watch_dir_network_mounts" + filename = "test_watch.txt" + content = "This file will be watched." + new_content = "This file has been modified." + + sandbox.files.make_dir(dirname) + sandbox.files.write(f"{dirname}/{filename}", content) + + # The flag only lifts the network-mount restriction — watching a regular + # directory must work the same with it enabled. + handle = sandbox.files.watch_dir(dirname, allow_network_mounts=True) + sandbox.files.write(f"{dirname}/{filename}", new_content) + + events = handle.get_new_events() + write_event = None + for event in events: + if event.type == FilesystemEventType.WRITE and event.name == filename: + write_event = event + break + + assert write_event is not None, ( + f"Expected WRITE event for {filename}, but got events: {events}" + ) + + handle.stop() + + +def test_watch_directory_changes(sandbox: Sandbox): + dirname = "test_watch_dir" + filename = "test_watch.txt" + content = "This file will be watched." + new_content = "This file has been modified." + + sandbox.files.make_dir(dirname) + sandbox.files.write(f"{dirname}/{filename}", content) + + handle = sandbox.files.watch_dir(dirname) + sandbox.files.write(f"{dirname}/{filename}", new_content) + + events = handle.get_new_events() + write_event = None + for event in events: + if event.type == FilesystemEventType.WRITE and event.name == filename: + write_event = event + break + + assert write_event is not None, ( + f"Expected WRITE event for {filename}, but got events: {events}" + ) + assert write_event.name == filename + + handle.stop() + + +def test_watch_iterated(sandbox: Sandbox): + dirname = "test_watch_dir_iterated" + filename = "test_watch_iterated.txt" + content = "This file will be watched." + new_content = "This file has been modified." + + sandbox.files.make_dir(dirname) + handle = sandbox.files.watch_dir(dirname) + sandbox.files.write(f"{dirname}/{filename}", content) + + events = handle.get_new_events() + assert len(events) == 3 + + sandbox.files.write(f"{dirname}/{filename}", new_content) + events = handle.get_new_events() + for event in events: + if event.type == FilesystemEventType.WRITE and event.name == filename: + break + + handle.stop() + + +def test_watch_recursive_directory_changes(sandbox: Sandbox): + dirname = "test_recursive_watch_dir" + nested_dirname = "test_nested_watch_dir" + filename = "test_watch.txt" + content = "This file will be watched." + + sandbox.files.remove(dirname) + sandbox.files.make_dir(f"{dirname}/{nested_dirname}") + + handle = sandbox.files.watch_dir(dirname, recursive=True) + sandbox.files.write(f"{dirname}/{nested_dirname}/{filename}", content) + + events = handle.get_new_events() + assert len(events) == 3 + expected_filename = f"{nested_dirname}/{filename}" + assert events[0].type == FilesystemEventType.CREATE + assert events[0].name == expected_filename + + handle.stop() + + +def test_watch_recursive_directory_after_nested_folder_addition(sandbox: Sandbox): + dirname = "test_recursive_watch_dir_add" + nested_dirname = "test_nested_watch_dir" + filename = "test_watch.txt" + content = "This file will be watched." + + sandbox.files.remove(dirname) + sandbox.files.make_dir(dirname) + + handle = sandbox.files.watch_dir(dirname, recursive=True) + + sandbox.files.make_dir(f"{dirname}/{nested_dirname}") + sandbox.files.write(f"{dirname}/{nested_dirname}/{filename}", content) + + expected_filename = f"{nested_dirname}/{filename}" + + events = handle.get_new_events() + file_changed = False + folder_created = False + for event in events: + if event.type == FilesystemEventType.WRITE and event.name == expected_filename: + file_changed = True + continue + if event.type == FilesystemEventType.CREATE and event.name == nested_dirname: + folder_created = True + + assert folder_created + assert file_changed + + handle.stop() + + +def test_watch_non_existing_directory(sandbox: Sandbox): + dirname = "non_existing_watch_dir" + + with pytest.raises(FileNotFoundException): + sandbox.files.watch_dir(dirname) + + +def test_watch_file(sandbox: Sandbox): + filename = "test_watch.txt" + sandbox.files.write(filename, "This file will be watched.") + + with pytest.raises(SandboxException): + sandbox.files.watch_dir(filename) + + +def test_watch_file_with_secured_envd(sandbox_factory): + sbx = sandbox_factory(timeout=30, secure=True) + + sbx.files.watch_dir("/home/user/") + sbx.files.write("test_watch.txt", "This file will be watched.") diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_write.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_write.py new file mode 100644 index 0000000..5416607 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_write.py @@ -0,0 +1,218 @@ +import io +import uuid + +from e2b.sandbox.filesystem.filesystem import FileType, WriteInfo, WriteEntry + + +def test_write_text_file(sandbox, debug): + filename = "test_write.txt" + content = "This is a test file." + + info = sandbox.files.write(filename, content) + assert info.path == f"/home/user/{filename}" + assert info.type == FileType.FILE + + exists = sandbox.files.exists(filename) + assert exists + + read_content = sandbox.files.read(filename) + assert read_content == content + + if debug: + sandbox.files.remove(filename) + + +def test_write_binary_file(sandbox, debug): + filename = "test_write.bin" + text = "This is a test binary file." + # equivalent to `open("path/to/local/file", "rb")` + content = io.BytesIO(text.encode("utf-8")) + + info = sandbox.files.write(filename, content) + assert info.path == f"/home/user/{filename}" + + exists = sandbox.files.exists(filename) + assert exists + + read_content = sandbox.files.read(filename) + assert read_content == text + + if debug: + sandbox.files.remove(filename) + + +def test_write_multiple_files(sandbox, debug): + num_test_files = 10 + + # Attempt to write with empty files array + empty_info = sandbox.files.write_files([]) + assert isinstance(empty_info, list) + assert len(empty_info) == 0 + + # Attempt to write with no files + assert sandbox.files.write_files([]) == [] + + # Attempt to write with one file in array + one_file_path = "one_test_file.txt" + info = sandbox.files.write_files( + [WriteEntry(path=one_file_path, data="This is a test file.")] + ) + + assert isinstance(info, list) + assert len(info) == 1 + info = info[0] + assert isinstance(info, WriteInfo) + assert info.path == "/home/user/one_test_file.txt" + exists = sandbox.files.exists(info.path) + assert exists + + read_content = sandbox.files.read(info.path) + assert read_content == "This is a test file." + + # Attempt to write with multiple files in array + files = [] + for i in range(num_test_files): + path = f"test_write_{i}.txt" + content = f"This is a test file {i}." + files.append(WriteEntry(path=path, data=content)) + + infos = sandbox.files.write_files(files) + assert isinstance(infos, list) + assert len(infos) == len(files) + for i, info in enumerate(infos): + assert isinstance(info, WriteInfo) + assert info.path == f"/home/user/test_write_{i}.txt" + assert info.type == FileType.FILE + exists = sandbox.files.exists(path) + assert exists + + read_content = sandbox.files.read(info.path) + assert read_content == files[i]["data"] + + if debug: + sandbox.files.remove(one_file_path) + for i in range(num_test_files): + sandbox.files.remove(f"test_write_{i}.txt") + + +def test_overwrite_file(sandbox, debug): + filename = "test_overwrite.txt" + initial_content = "Initial content." + new_content = "New content." + + sandbox.files.write(filename, initial_content) + sandbox.files.write(filename, new_content) + read_content = sandbox.files.read(filename) + assert read_content == new_content + + if debug: + sandbox.files.remove(filename) + + +def test_write_to_non_existing_directory(sandbox, debug): + filename = "non_existing_dir/test_write.txt" + content = "This should succeed too." + + sandbox.files.write(filename, content) + exists = sandbox.files.exists(filename) + assert exists + + read_content = sandbox.files.read(filename) + assert read_content == content + + if debug: + sandbox.files.remove(filename) + + +def test_write_with_secured_envd(sandbox_factory): + filename = f"non_existing_dir_{uuid.uuid4()}/test_write.txt" + content = "This should succeed too." + + sbx = sandbox_factory(timeout=30, secure=True) + assert sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + + sbx.files.write(filename, content) + + exists = sbx.files.exists(filename) + assert exists + + read_content = sbx.files.read(filename) + assert read_content == content + + +def test_write_files_with_different_data_types(sandbox, debug): + text_data = "Text string data" + bytes_data = b"Bytes data" + bytes_io_data = io.BytesIO(b"BytesIO data") + string_io_data = io.StringIO("StringIO data") + + files = [ + WriteEntry(path="writefiles_text.txt", data=text_data), + WriteEntry(path="writefiles_bytes.bin", data=bytes_data), + WriteEntry(path="writefiles_bytesio.bin", data=bytes_io_data), + WriteEntry(path="writefiles_stringio.txt", data=string_io_data), + ] + + infos = sandbox.files.write_files(files) + + assert len(infos) == 4 + + text_content = sandbox.files.read("writefiles_text.txt") + assert text_content == text_data + + bytes_content = sandbox.files.read("writefiles_bytes.bin") + assert bytes_content == "Bytes data" + + bytes_io_content = sandbox.files.read("writefiles_bytesio.bin") + assert bytes_io_content == "BytesIO data" + + string_io_content = sandbox.files.read("writefiles_stringio.txt") + assert string_io_content == "StringIO data" + + if debug: + for file in files: + sandbox.files.remove(file["path"]) + + +def test_write_io_with_octet_stream(sandbox, debug): + filename = "test_write_octet_io.bin" + text = "Streamed octet-stream upload. " * 10_000 + content = io.BytesIO(text.encode("utf-8")) + + info = sandbox.files.write(filename, content, use_octet_stream=True) + assert info.path == f"/home/user/{filename}" + + read_content = sandbox.files.read(filename) + assert read_content == text + + if debug: + sandbox.files.remove(filename) + + +def test_write_text_io_with_octet_stream(sandbox, debug): + filename = "test_write_octet_text_io.txt" + text = "Streamed text octet-stream upload." + + sandbox.files.write(filename, io.StringIO(text), use_octet_stream=True) + + read_content = sandbox.files.read(filename) + assert read_content == text + + if debug: + sandbox.files.remove(filename) + + +def test_write_io_with_octet_stream_and_gzip(sandbox, debug): + filename = "test_write_octet_io_gzip.bin" + text = "Streamed gzipped octet-stream upload. " * 10_000 + content = io.BytesIO(text.encode("utf-8")) + + sandbox.files.write(filename, content, use_octet_stream=True, gzip=True) + + read_content = sandbox.files.read(filename) + assert read_content == text + + if debug: + sandbox.files.remove(filename) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/pty/test_pty.py b/packages/python-sdk/tests/sync/sandbox_sync/pty/test_pty.py new file mode 100644 index 0000000..1dd6255 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/pty/test_pty.py @@ -0,0 +1,17 @@ +from e2b import Sandbox +from e2b.sandbox.commands.command_handle import PtySize + + +def test_pty(sandbox: Sandbox): + def append_data(data: list, x: bytes): + data.append(x.decode("utf-8")) + + terminal = sandbox.pty.create(PtySize(80, 24), envs={"ABC": "123"}, cwd="/") + + sandbox.pty.send_stdin(terminal.pid, b"echo $ABC\nexit\n") + + output = [] + result = terminal.wait(on_pty=lambda x: append_data(output, x)) + assert result.exit_code == 0 + + assert "123" in "\n".join(output) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/pty/test_pty_connect.py b/packages/python-sdk/tests/sync/sandbox_sync/pty/test_pty_connect.py new file mode 100644 index 0000000..bfabbc7 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/pty/test_pty_connect.py @@ -0,0 +1,30 @@ +import pytest + +from e2b.sandbox.commands.command_handle import PtySize + + +@pytest.mark.skip_debug() +def test_connect_to_pty(sandbox_factory): + sandbox = sandbox_factory(timeout=100) + output = [] + + def append_data(data: list, x: bytes): + data.append(x.decode("utf-8")) + + terminal = sandbox.pty.create(PtySize(80, 24), envs={"FOO": "bar"}) + + sandbox.pty.send_stdin(terminal.pid, b"echo $FOO\n") + + terminal.disconnect() + + # Now connect again, with a new on_pty handler + reconnect_handle = sandbox.pty.connect(terminal.pid) + + sandbox.pty.send_stdin(terminal.pid, b"echo $FOO\nexit\n") + + result = reconnect_handle.wait(on_pty=lambda x: append_data(output, x)) + + assert terminal.pid == reconnect_handle.pid + assert result.exit_code == 0 + + assert "bar" in "".join(output) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/pty/test_pty_kill.py b/packages/python-sdk/tests/sync/sandbox_sync/pty/test_pty_kill.py new file mode 100644 index 0000000..e651a84 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/pty/test_pty_kill.py @@ -0,0 +1,20 @@ +import pytest + +from e2b import Sandbox, CommandExitException +from e2b.sandbox.commands.command_handle import PtySize + + +def test_kill_pty(sandbox: Sandbox): + terminal = sandbox.pty.create(PtySize(80, 24)) + + assert sandbox.pty.kill(terminal.pid) + + # The PTY process should no longer be running. + with pytest.raises(CommandExitException): + sandbox.commands.run(f"kill -0 {terminal.pid}") + + +def test_kill_non_existing_pty(sandbox: Sandbox): + non_existing_pid = 999999 + + assert not sandbox.pty.kill(non_existing_pid) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/pty/test_resize.py b/packages/python-sdk/tests/sync/sandbox_sync/pty/test_resize.py new file mode 100644 index 0000000..726b34e --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/pty/test_resize.py @@ -0,0 +1,28 @@ +from e2b import Sandbox +from e2b.sandbox.commands.command_handle import PtySize + + +def test_resize(sandbox: Sandbox): + def append_data(data: list, x: bytes): + data.append(x.decode("utf-8")) + + terminal = sandbox.pty.create(PtySize(cols=80, rows=24)) + + sandbox.pty.send_stdin(terminal.pid, b"tput cols\nexit\n") + + output = [] + result = terminal.wait(on_pty=lambda x: append_data(output, x)) + assert result.exit_code == 0 + + assert "80" in "".join(output) + + terminal = sandbox.pty.create(PtySize(cols=80, rows=24)) + + sandbox.pty.resize(terminal.pid, PtySize(cols=100, rows=24)) + sandbox.pty.send_stdin(terminal.pid, b"tput cols\nexit\n") + + output = [] + result = terminal.wait(on_pty=lambda x: append_data(output, x)) + assert result.exit_code == 0 + + assert "100" in "".join(output) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/pty/test_send_input.py b/packages/python-sdk/tests/sync/sandbox_sync/pty/test_send_input.py new file mode 100644 index 0000000..15c5df0 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/pty/test_send_input.py @@ -0,0 +1,9 @@ +from e2b import Sandbox +from e2b.sandbox.commands.command_handle import PtySize + + +def test_send_input(sandbox: Sandbox): + terminal = sandbox.pty.create(PtySize(cols=80, rows=24)) + sandbox.pty.send_stdin(terminal.pid, b"exit\n") + result = terminal.wait() + assert result.exit_code == 0 diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_config_propagation.py b/packages/python-sdk/tests/sync/sandbox_sync/test_config_propagation.py new file mode 100644 index 0000000..4b0c9c5 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_config_propagation.py @@ -0,0 +1,115 @@ +from unittest.mock import Mock + +import pytest +from packaging.version import Version + +from e2b import Sandbox +from e2b.api import SandboxCreateResponse +from e2b.connection_config import ConnectionConfig +import e2b.sandbox_sync.main as sandbox_sync_main + +BASE_DOMAIN = "base.e2b.dev" +BASE_REQUEST_TIMEOUT = 11 +BASE_DEBUG = False +BASE_HEADERS = {"X-Test": "base"} + + +def create_sandbox(monkeypatch, api_key: str) -> Sandbox: + monkeypatch.setattr( + sandbox_sync_main, "Filesystem", lambda *args, **kwargs: object() + ) + monkeypatch.setattr(sandbox_sync_main, "Commands", lambda *args, **kwargs: object()) + monkeypatch.setattr(sandbox_sync_main, "Pty", lambda *args, **kwargs: object()) + monkeypatch.setattr(sandbox_sync_main, "Git", lambda *args, **kwargs: object()) + + return Sandbox( + sandbox_id="sbx-test", + sandbox_domain="sandbox.e2b.dev", + envd_version=Version("0.2.4"), + envd_access_token="tok", + traffic_access_token="tok", + connection_config=ConnectionConfig( + api_key=api_key, + domain=BASE_DOMAIN, + request_timeout=BASE_REQUEST_TIMEOUT, + debug=BASE_DEBUG, + api_headers=BASE_HEADERS, + ), + ) + + +@pytest.mark.skip_debug() +def test_pause_passes_connection_config_without_overrides(monkeypatch, test_api_key): + mock_pause = Mock(return_value="sbx-test") + monkeypatch.setattr(sandbox_sync_main.SandboxApi, "_cls_pause", mock_pause) + + sandbox = create_sandbox(monkeypatch, test_api_key) + sandbox.pause() + + mock_pause.assert_called_once() + assert mock_pause.call_args.kwargs["sandbox_id"] == "sbx-test" + assert mock_pause.call_args.kwargs["api_key"] == test_api_key + assert mock_pause.call_args.kwargs["domain"] == BASE_DOMAIN + assert mock_pause.call_args.kwargs["request_timeout"] == BASE_REQUEST_TIMEOUT + assert mock_pause.call_args.kwargs["debug"] == BASE_DEBUG + assert mock_pause.call_args.kwargs["headers"]["X-Test"] == BASE_HEADERS["X-Test"] + + +@pytest.mark.skip_debug() +def test_pause_applies_overrides(monkeypatch, test_api_key): + mock_pause = Mock(return_value="sbx-test") + monkeypatch.setattr(sandbox_sync_main.SandboxApi, "_cls_pause", mock_pause) + + sandbox = create_sandbox(monkeypatch, test_api_key) + sandbox.pause( + domain="override.e2b.dev", + request_timeout=20, + api_headers={"X-Extra": "1"}, + ) + + mock_pause.assert_called_once() + assert mock_pause.call_args.kwargs["sandbox_id"] == "sbx-test" + assert mock_pause.call_args.kwargs["api_key"] == test_api_key + assert mock_pause.call_args.kwargs["domain"] == "override.e2b.dev" + assert mock_pause.call_args.kwargs["request_timeout"] == 20 + assert mock_pause.call_args.kwargs["debug"] == BASE_DEBUG + assert mock_pause.call_args.kwargs["headers"]["X-Test"] == BASE_HEADERS["X-Test"] + assert mock_pause.call_args.kwargs["headers"]["X-Extra"] == "1" + + +@pytest.mark.skip_debug() +def test_connect_sets_stable_host_routing_headers(monkeypatch, test_api_key): + mock_connect = Mock( + return_value=SandboxCreateResponse( + sandbox_id="sbx-test", + sandbox_domain="e2b.app", + envd_version="0.4.0", + envd_access_token="tok", + traffic_access_token="traffic", + ) + ) + monkeypatch.setattr(sandbox_sync_main.SandboxApi, "_cls_connect", mock_connect) + + monkeypatch.setattr(ConnectionConfig, "_integration", "testing/version") + config = ConnectionConfig( + api_key=test_api_key, + headers=BASE_HEADERS, + ) + sandbox = Sandbox.connect( + "sbx-test", + **config.get_api_params(), + ) + + assert sandbox.envd_api_url == "https://sandbox.e2b.app" + assert "X-Test" not in sandbox.connection_config.sandbox_headers + assert sandbox.connection_config.sandbox_headers["User-Agent"].startswith( + "e2b-python-sdk/" + ) + assert sandbox.connection_config.sandbox_headers["User-Agent"].endswith( + " testing/version" + ) + assert sandbox.connection_config.sandbox_headers["E2b-Sandbox-Id"] == "sbx-test" + assert sandbox.connection_config.sandbox_headers["E2b-Sandbox-Port"] == str( + ConnectionConfig.envd_port + ) + assert sandbox.connection_config.sandbox_headers["X-Access-Token"] == "tok" diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_connect.py b/packages/python-sdk/tests/sync/sandbox_sync/test_connect.py new file mode 100644 index 0000000..b9d137c --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_connect.py @@ -0,0 +1,136 @@ +import uuid +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from e2b import Sandbox +from e2b.api.client.api.sandboxes import post_sandboxes_sandbox_id_connect +from e2b.api.client.models import Sandbox as SandboxModel +import e2b.sandbox_sync.main as sandbox_sync_main + + +@pytest.mark.skip_debug() +def test_connect(sandbox_factory): + sbx = sandbox_factory(timeout=10) + + assert sbx.is_running() + + sbx_connection = Sandbox.connect(sbx.sandbox_id) + assert sbx_connection.is_running() + + +@pytest.mark.skip_debug() +def test_connect_with_secure(sandbox_factory): + dir_name = f"test_directory_{uuid.uuid4()}" + + sbx = sandbox_factory(timeout=10, secure=True) + + assert sbx.is_running() + + sbx_connection = Sandbox.connect(sbx.sandbox_id) + + sbx_connection.files.make_dir(dir_name) + files = sbx_connection.files.list(dir_name) + assert len(files) == 0 + + +@pytest.mark.skip_debug() +def test_connect_to_paused_sandbox_resumes(sandbox): + sandbox.pause() + assert not sandbox.is_running() + + resumed = Sandbox.connect(sandbox.sandbox_id) + assert resumed.is_running() + + +@pytest.mark.skip_debug() +def test_resume_does_not_shorten_timeout_on_running_sandbox(sandbox_factory): + # Create sandbox with a 300 second timeout + sbx = sandbox_factory(timeout=300) + assert sbx.is_running() + + # Get initial info to check end_at + info_before = Sandbox.get_info(sbx.sandbox_id) + + # Connect with a shorter timeout (10 seconds) + Sandbox.connect(sbx.sandbox_id, timeout=10) + + # Get info after connection + info_after = Sandbox.get_info(sbx.sandbox_id) + + # The end_at time should not have been shortened. It should be the same + assert info_after.end_at == info_before.end_at, ( + f"Timeout was shortened: before={info_before.end_at}, after={info_after.end_at}" + ) + + +@pytest.mark.skip_debug() +def test_connect_extends_timeout_on_running_sandbox(sandbox): + # Get initial info to check end_at + info_before = sandbox.get_info() + + # Connect with a longer timeout + Sandbox.connect(sandbox.sandbox_id, timeout=600) + + # Get info after connection + info_after = sandbox.get_info() + + # The end_at time should have been extended + assert info_after.end_at > info_before.end_at, ( + f"Timeout was not extended: before={info_before.end_at}, after={info_after.end_at}" + ) + + +def test_connect_in_debug_mode_does_not_call_api(monkeypatch, test_api_key): + mock_connect = Mock() + monkeypatch.setattr(sandbox_sync_main.SandboxApi, "_cls_connect", mock_connect) + + sbx = Sandbox.connect("sbx-debug", debug=True, api_key=test_api_key) + + mock_connect.assert_not_called() + assert sbx.sandbox_id == "sbx-debug" + assert sbx._envd_access_token is None + assert sbx.traffic_access_token is None + + +def test_connect_in_env_debug_mode_does_not_call_api(monkeypatch, test_api_key): + monkeypatch.setenv("E2B_DEBUG", "true") + mock_connect = Mock() + monkeypatch.setattr(sandbox_sync_main.SandboxApi, "_cls_connect", mock_connect) + + sbx = Sandbox.connect("sbx-debug", api_key=test_api_key) + + mock_connect.assert_not_called() + assert sbx.sandbox_id == "sbx-debug" + + +def test_instance_connect_in_debug_mode_does_not_call_api(monkeypatch, test_api_key): + mock_connect = Mock() + monkeypatch.setattr(sandbox_sync_main.SandboxApi, "_cls_connect", mock_connect) + + sbx = Sandbox.connect("sbx-debug", debug=True, api_key=test_api_key) + + assert sbx.connect() is sbx + mock_connect.assert_not_called() + + +def test_connect_normalizes_unset_tokens(monkeypatch, test_api_key): + # Tokens and domain are absent in the API response for non-secure sandboxes + model = SandboxModel( + client_id="client-id", + envd_version="0.2.4", + sandbox_id="sbx-test", + template_id="template-id", + ) + mock_request = Mock(return_value=SimpleNamespace(status_code=200, parsed=model)) + monkeypatch.setattr( + post_sandboxes_sandbox_id_connect, "sync_detailed", mock_request + ) + + sbx = Sandbox.connect("sbx-test", debug=False, api_key=test_api_key) + + mock_request.assert_called_once() + assert sbx._envd_access_token is None + assert sbx.traffic_access_token is None + assert "signature" not in sbx.download_url("test.txt") diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_create.py b/packages/python-sdk/tests/sync/sandbox_sync/test_create.py new file mode 100644 index 0000000..737e346 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_create.py @@ -0,0 +1,192 @@ +from time import sleep +from typing import Any, cast + +import httpx +import pytest + +from e2b import Sandbox, SandboxState +from e2b.api.client.models import ( + NewSandbox, + SandboxAutoResumeConfig, +) +from e2b.exceptions import InvalidArgumentException +from e2b.sandbox.sandbox_api import SandboxQuery + + +@pytest.mark.skip_debug() +def test_start(sandbox_factory): + sbx = sandbox_factory(timeout=5) + + assert sbx.is_running() + assert sbx._envd_version is not None + + +@pytest.mark.skip_debug() +def test_metadata(sandbox_factory): + sbx = sandbox_factory(timeout=5, metadata={"test-key": "test-value"}) + + paginator = Sandbox.list(query=SandboxQuery(metadata={"test-key": "test-value"})) + sandboxes = paginator.next_items() + + for sbx_info in sandboxes: + if sbx.sandbox_id == sbx_info.sandbox_id: + assert sbx_info.metadata is not None + assert sbx_info.metadata["test-key"] == "test-value" + break + else: + assert False, "Sandbox not found" + + +def test_create_payload_serializes_auto_resume_enabled(): + body = NewSandbox( + template_id="template-id", + auto_pause=True, + auto_resume=SandboxAutoResumeConfig(enabled=True), + ) + + assert body.to_dict()["autoPause"] is True + assert body.to_dict()["autoResume"] == {"enabled": True} + + +def test_create_payload_deserializes_auto_resume_enabled(): + body = NewSandbox.from_dict( + { + "templateID": "template-id", + "autoPause": False, + "autoResume": {"enabled": False}, + } + ) + + assert isinstance(body.auto_resume, SandboxAutoResumeConfig) + assert body.auto_resume.to_dict() == {"enabled": False} + + +@pytest.mark.skip_debug() +def test_filesystem_only_auto_pause_rejects_auto_resume(): + # A filesystem-only auto-pause snapshot can only be resumed explicitly, so + # combining keep_memory=False with auto_resume is rejected client-side. + with pytest.raises(InvalidArgumentException): + Sandbox.create( + timeout=3, + lifecycle={ + "on_timeout": {"action": "pause", "keep_memory": False}, + "auto_resume": True, + }, + ) + + +@pytest.mark.skip_debug() +def test_keep_memory_not_allowed_with_kill(): + # The discriminated union forbids keep_memory on action="kill" at type-check + # time; the runtime guard rejects it for callers that bypass the type + # (cast(Any, ...) feeds the deliberately type-invalid input). + with pytest.raises(InvalidArgumentException): + Sandbox.create( + timeout=3, + lifecycle=cast( + Any, {"on_timeout": {"action": "kill", "keep_memory": False}} + ), + ) + + +@pytest.mark.skip_debug() +def test_invalid_on_timeout_type_does_not_crash(sandbox_factory): + # An untyped/invalid on_timeout (e.g. None) must not crash create; it falls + # back to kill semantics like a missing on_timeout (the sandbox just starts). + sbx = sandbox_factory(timeout=10, lifecycle=cast(Any, {"on_timeout": None})) + assert sbx.is_running() + + +@pytest.mark.skip_debug() +def test_keep_memory_none_defaults_to_full_memory(sandbox_factory): + # An explicit None keep_memory must default to full memory (not filesystem-only): + # the timeout auto-pause then resumes the SAME sandbox in place (memory restore), + # so the boot id is unchanged. A changed boot id would mean None was wrongly + # treated as filesystem-only (cold boot). + sbx = sandbox_factory( + timeout=60, + lifecycle={"on_timeout": {"action": "pause", "keep_memory": None}}, + ) + boot_before = sbx.files.read("/proc/sys/kernel/random/boot_id").strip() + + sbx.set_timeout(0) # force the timeout auto-pause now + for _ in range(150): + if not sbx.is_running(): + break + sleep(0.2) + assert not sbx.is_running() + + resumed = sbx.connect() + assert resumed.sandbox_id == sbx.sandbox_id # same sandbox + boot_after = resumed.files.read("/proc/sys/kernel/random/boot_id").strip() + assert boot_after == boot_before # memory restore in place, not a cold boot + + +@pytest.mark.skip_debug() +def test_auto_pause_filesystem_only_reboots(sandbox_factory): + # keep_memory=False makes the timeout auto-pause filesystem-only, so resuming + # cold-boots the sandbox from disk. + sandbox = sandbox_factory( + timeout=3, + lifecycle={"on_timeout": {"action": "pause", "keep_memory": False}}, + ) + + marker = "auto-pause-fs-only" + sandbox.files.write("/home/user/auto-pause-marker.txt", marker) + boot_before = sandbox.files.read("/proc/sys/kernel/random/boot_id").strip() + + sleep(5) + + assert sandbox.get_info().state == SandboxState.PAUSED + + # A filesystem-only snapshot cannot auto-resume on traffic; connect resumes + # it by cold-booting. + resumed = sandbox.connect() + + persisted = resumed.files.read("/home/user/auto-pause-marker.txt").strip() + assert persisted == marker + + boot_after = resumed.files.read("/proc/sys/kernel/random/boot_id").strip() + assert boot_after != boot_before + + +@pytest.mark.skip_debug() +def test_auto_pause_without_auto_resume_requires_connect(sandbox_factory): + sandbox = sandbox_factory( + timeout=3, + lifecycle={"on_timeout": "pause", "auto_resume": False}, + ) + + sleep(5) + + assert sandbox.get_info().state == SandboxState.PAUSED + assert not sandbox.is_running() + + sandbox.connect() + + assert sandbox.get_info().state == SandboxState.RUNNING + assert sandbox.is_running() + + +@pytest.mark.skip_debug() +def test_auto_resume_wakes_on_http_request(sandbox_factory): + sandbox = sandbox_factory( + timeout=3, + lifecycle={"on_timeout": "pause", "auto_resume": True}, + ) + + cmd = sandbox.commands.run("python3 -m http.server 8000", background=True) + try: + sleep(5) + + url = f"https://{sandbox.get_host(8000)}" + res = httpx.get(url, timeout=15.0) + + assert res.status_code == 200 + assert sandbox.get_info().state == SandboxState.RUNNING + assert sandbox.is_running() + finally: + try: + cmd.kill() + except Exception: + pass diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_host.py b/packages/python-sdk/tests/sync/sandbox_sync/test_host.py new file mode 100644 index 0000000..00edae4 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_host.py @@ -0,0 +1,24 @@ +import httpx + +from time import sleep + + +def test_ping_server(sandbox, debug, helpers): + cmd = sandbox.commands.run("python -m http.server 8001", background=True) + + try: + host = sandbox.get_host(8001) + status_code = None + for _ in range(20): + res = httpx.get(f"{'http' if debug else 'https'}://{host}") + status_code = res.status_code + if res.status_code == 200: + break + sleep(0.5) + + assert status_code == 200 + except Exception as e: + helpers.check_cmd_exit_error(cmd) + raise e + finally: + cmd.kill() diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_internet_access.py b/packages/python-sdk/tests/sync/sandbox_sync/test_internet_access.py new file mode 100644 index 0000000..d35216c --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_internet_access.py @@ -0,0 +1,42 @@ +import pytest + +from e2b.sandbox.commands.command_handle import CommandExitException + + +@pytest.mark.skip_debug() +def test_internet_access_enabled(sandbox_factory): + """Test that sandbox with internet access enabled can reach external websites.""" + sbx = sandbox_factory(allow_internet_access=True) + + # Test internet connectivity by making a curl request to a reliable external site + result = sbx.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://connectivitycheck.gstatic.com/generate_204" + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "204" + + +@pytest.mark.skip_debug() +def test_internet_access_disabled(sandbox_factory): + """Test that sandbox with internet access disabled cannot reach external websites.""" + sbx = sandbox_factory(allow_internet_access=False) + + # Test that internet connectivity is blocked by making a curl request + with pytest.raises(CommandExitException) as exc_info: + sbx.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://connectivitycheck.gstatic.com/generate_204" + ) + # The command should fail or timeout when internet access is disabled + assert exc_info.value.exit_code != 0 + + +@pytest.mark.skip_debug() +def test_internet_access_default(sandbox): + """Test that sandbox with default settings (no explicit allow_internet_access) has internet access.""" + + # Test internet connectivity by making a curl request to a reliable external site + result = sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://connectivitycheck.gstatic.com/generate_204" + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "204" diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_kill.py b/packages/python-sdk/tests/sync/sandbox_sync/test_kill.py new file mode 100644 index 0000000..45628e9 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_kill.py @@ -0,0 +1,16 @@ +import pytest + +from e2b import Sandbox, SandboxQuery, SandboxState + + +@pytest.mark.skip_debug() +def test_kill(sandbox: Sandbox, sandbox_test_id: str): + sandbox.kill() + + paginator = Sandbox.list( + query=SandboxQuery( + state=[SandboxState.RUNNING], metadata={"sandbox_test_id": sandbox_test_id} + ) + ) + sandboxes = paginator.next_items() + assert sandbox.sandbox_id not in [s.sandbox_id for s in sandboxes] diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_metrics.py b/packages/python-sdk/tests/sync/sandbox_sync/test_metrics.py new file mode 100644 index 0000000..3846ecf --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_metrics.py @@ -0,0 +1,62 @@ +import datetime +import time + +import pytest + + +@pytest.mark.skip_debug() +@pytest.mark.timeout(60) +def test_sbx_metrics(sandbox_factory) -> None: + sbx = sandbox_factory(timeout=60) + + # Wait for the sandbox to have some metrics + metrics = [] + for _ in range(60): + metrics = sbx.get_metrics() + if len(metrics) > 0: + break + time.sleep(0.5) + + assert len(metrics) > 0 + + metric = metrics[0] + assert metric.cpu_count is not None + assert metric.cpu_used_pct is not None + assert metric.mem_used is not None + assert metric.mem_total is not None + assert metric.disk_used is not None + assert metric.disk_total is not None + + +@pytest.mark.skip_debug() +@pytest.mark.timeout(60) +def test_sbx_metrics_time_range(sandbox_factory) -> None: + start_time = datetime.datetime.now(datetime.timezone.utc) + sbx = sandbox_factory(timeout=60) + + # Wait for the sandbox to have some metrics within the test's time window + metrics = [] + end_time = start_time + for _ in range(60): + end_time = datetime.datetime.now(datetime.timezone.utc) + metrics = sbx.get_metrics(start=start_time, end=end_time) + if len(metrics) > 0: + break + time.sleep(0.5) + + assert len(metrics) > 0 + + # All returned metrics must fall within the requested time range + # (10s slack - metric timestamps are aligned to collection buckets, + # currently 5s, and the query params are second-precision) + slack = 10 + for metric in metrics: + assert metric.timestamp.timestamp() >= start_time.timestamp() - slack + assert metric.timestamp.timestamp() <= end_time.timestamp() + slack + + # A time range from before the sandbox existed must return no metrics + metrics = sbx.get_metrics( + start=start_time - datetime.timedelta(hours=1), + end=start_time - datetime.timedelta(minutes=30), + ) + assert len(metrics) == 0 diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_network.py b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py new file mode 100644 index 0000000..a921730 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_network.py @@ -0,0 +1,313 @@ +import json +import time + +import httpx + +import pytest + +from e2b import SandboxNetworkOpts +from e2b.sandbox.commands.command_handle import CommandExitException + + +def wait_for_status( + client: httpx.Client, + url: str, + status_code: int, + headers: dict[str, str] | None = None, + timeout: float = 15, +) -> httpx.Response: + deadline = time.monotonic() + timeout + response: httpx.Response | None = None + + while time.monotonic() < deadline: + response = client.get(url, headers=headers, follow_redirects=True) + if response.status_code == status_code: + return response + time.sleep(1) + + assert response is not None + return response + + +@pytest.mark.skip_debug() +def test_allow_specific_ip_with_deny_all(sandbox_factory): + """Test that sandbox with denyOut all and allowOut creates a whitelist.""" + sandbox = sandbox_factory( + network=SandboxNetworkOpts( + deny_out=lambda ctx: [ctx.all_traffic], allow_out=["1.1.1.1"] + ) + ) + + # Test that allowed IP works + result = sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "301" + + # Test that other IPs are denied + with pytest.raises(CommandExitException) as exc_info: + sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8" + ) + assert exc_info.value.exit_code != 0 + + +@pytest.mark.skip_debug() +def test_deny_specific_ip(sandbox_factory): + """Test that sandbox with denyOut denies specified IP addresses.""" + sandbox = sandbox_factory(network=SandboxNetworkOpts(deny_out=["8.8.8.8"])) + + # Test that denied IP fails + with pytest.raises(CommandExitException) as exc_info: + sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8" + ) + assert exc_info.value.exit_code != 0 + + # Test that other IPs work + result = sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "301" + + +@pytest.mark.skip_debug() +def test_deny_all_traffic(sandbox_factory): + """Test that sandbox can deny all traffic using the all_traffic selector.""" + sandbox = sandbox_factory( + network=SandboxNetworkOpts(deny_out=lambda ctx: [ctx.all_traffic]), timeout=30 + ) + + # Test that all traffic is denied + with pytest.raises(CommandExitException) as exc_info: + sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://1.1.1.1" + ) + assert exc_info.value.exit_code != 0 + + with pytest.raises(CommandExitException) as exc_info: + sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8" + ) + assert exc_info.value.exit_code != 0 + + +@pytest.mark.skip_debug() +def test_allow_takes_precedence_over_deny(sandbox_factory): + """Test that allowOut takes precedence over denyOut.""" + sandbox = sandbox_factory( + network=SandboxNetworkOpts( + deny_out=lambda ctx: [ctx.all_traffic], allow_out=["1.1.1.1", "8.8.8.8"] + ) + ) + + # Test that 1.1.1.1 works (explicitly allowed) + result1 = sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert result1.exit_code == 0 + assert result1.stdout.strip() == "301" + + # Test that 8.8.8.8 also works (explicitly allowed, takes precedence over deny_out) + result2 = sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8" + ) + assert result2.exit_code == 0 + assert result2.stdout.strip() == "302" + + +@pytest.mark.skip_debug() +def test_allow_public_traffic_false(sandbox_factory): + """Test that sandbox with allow_public_traffic=False requires traffic access token.""" + sandbox = sandbox_factory( + secure=True, network=SandboxNetworkOpts(allow_public_traffic=False) + ) + + # Verify the sandbox was created successfully and has a traffic access token + assert sandbox.traffic_access_token is not None + + # Start a simple HTTP server in the sandbox + port = 8080 + sandbox.commands.run( + f"python3 -m http.server {port}", + background=True, + ) + + # Wait for server to start + time.sleep(3) + + # Get the public URL for the sandbox + sandbox_url = f"https://{sandbox.get_host(port)}" + + with httpx.Client() as client: + # Test 1: Request without traffic access token should fail with 403 + response = client.get(sandbox_url, follow_redirects=True) + assert response.status_code == 403 + + # Test 2: Request with valid traffic access token should succeed + headers = {"e2b-traffic-access-token": sandbox.traffic_access_token} + response = wait_for_status(client, sandbox_url, 200, headers=headers) + assert response.status_code == 200 + + +@pytest.mark.skip_debug() +def test_allow_public_traffic_true(sandbox_factory): + """Test that sandbox with allow_public_traffic=True works without token.""" + sandbox = sandbox_factory(network=SandboxNetworkOpts(allow_public_traffic=True)) + + # Start a simple HTTP server in the sandbox + port = 8080 + sandbox.commands.run( + f"python3 -m http.server {port}", + background=True, + ) + + # Wait for server to start + time.sleep(3) + + # Get the public URL for the sandbox + sandbox_url = f"https://{sandbox.get_host(port)}" + + with httpx.Client() as client: + # Request without traffic access token should succeed (public access enabled) + response = wait_for_status(client, sandbox_url, 200) + assert response.status_code == 200 + + +@pytest.mark.skip_debug() +def test_firewall_transform_injects_headers(sandbox_factory): + """Test that a firewall rule with a transform injects headers into outbound requests.""" + injected_header = "X-E2B-Test-Token" + injected_value = "e2b-transform-value-123" + + network: SandboxNetworkOpts = { + "rules": { + "httpbin.e2b.team": [ + {"transform": {"headers": {injected_header: injected_value}}}, + ], + }, + } + sandbox = sandbox_factory(network=network) + + result = sandbox.commands.run( + "curl -sS --max-time 10 https://httpbin.e2b.team/headers" + ) + assert result.exit_code == 0 + + parsed = json.loads(result.stdout) + reflected = parsed["headers"].get(injected_header) + assert reflected == injected_value, ( + f"expected httpbin to reflect {injected_header}={injected_value}, " + f"got headers: {parsed['headers']}" + ) + + +@pytest.mark.skip_debug() +def test_update_network_applies_restrictions(sandbox_factory): + """update_network can add egress restrictions to a running sandbox.""" + sandbox = sandbox_factory() + + # Baseline: 8.8.8.8 reachable. + before = sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8" + ) + assert before.exit_code == 0 + + sandbox.update_network({"deny_out": ["8.8.8.8"]}) + + # 8.8.8.8 is now denied. + with pytest.raises(CommandExitException) as exc_info: + sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8" + ) + assert exc_info.value.exit_code != 0 + + # Other destinations stay reachable. + result = sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert result.exit_code == 0 + + +@pytest.mark.skip_debug() +def test_update_network_clears_existing_rules(sandbox_factory): + """update_network replaces all egress rules; omitted fields are cleared.""" + sandbox = sandbox_factory( + network=SandboxNetworkOpts( + deny_out=lambda ctx: [ctx.all_traffic], + allow_out=["1.1.1.1"], + ) + ) + + # Baseline from create-time config: 8.8.8.8 denied. + with pytest.raises(CommandExitException): + sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8" + ) + + # Empty update clears allow_out / deny_out entirely. + sandbox.update_network({}) + + r1 = sandbox.commands.run("curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1") + assert r1.exit_code == 0 + + r2 = sandbox.commands.run("curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8") + assert r2.exit_code == 0 + + +@pytest.mark.skip_debug() +def test_mask_request_host(sandbox_factory): + """Test that mask_request_host modifies the Host header correctly.""" + sandbox = sandbox_factory( + network=SandboxNetworkOpts(mask_request_host="custom-host.example.com:${PORT}"), + timeout=60, + ) + + import time + + import httpx + + port = 8080 + output_file = "/tmp/headers.txt" + + # Start a Python HTTP server that captures request headers and writes them to a file + sandbox.commands.run( + f"""python3 -c " +import http.server, json +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + with open('{output_file}', 'w') as f: + for k, v in self.headers.items(): + f.write(k + ': ' + v + chr(10)) + self.send_response(200) + self.end_headers() + def log_message(self, *a): pass +http.server.HTTPServer(('', {port}), H).handle_request() +" """, + background=True, + ) + + time.sleep(2) + + # Get the public URL for the sandbox + sandbox_url = f"https://{sandbox.get_host(port)}" + + # Make a request from OUTSIDE the sandbox through the proxy + # The Host header should be modified according to mask_request_host + with httpx.Client() as client: + try: + client.get(sandbox_url, timeout=5.0) + except Exception: + pass + + time.sleep(1) + + # Read the captured headers from inside the sandbox + result = sandbox.commands.run(f"cat {output_file}") + + # Verify the Host header was modified according to mask_request_host + assert "Host:" in result.stdout + assert "custom-host.example.com" in result.stdout + assert str(port) in result.stdout diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_secure.py b/packages/python-sdk/tests/sync/sandbox_sync/test_secure.py new file mode 100644 index 0000000..12bb426 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_secure.py @@ -0,0 +1,26 @@ +import pytest + +from e2b import Sandbox + + +@pytest.mark.skip_debug() +def test_start_secured(sandbox_factory): + sbx = sandbox_factory(timeout=5, secure=True) + + assert sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + + +@pytest.mark.skip_debug() +def test_connect_to_secured(sandbox_factory): + sbx = sandbox_factory(timeout=5, secure=True) + + assert sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + + sbx_connection = Sandbox.connect(sbx.sandbox_id) + assert sbx_connection.is_running() + assert sbx_connection._envd_version is not None + assert sbx_connection._envd_access_token is not None diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot.py b/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot.py new file mode 100644 index 0000000..c3d5c7a --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot.py @@ -0,0 +1,15 @@ +import pytest +from e2b import Sandbox + + +@pytest.mark.skip_debug() +def test_snapshot(sandbox: Sandbox): + assert sandbox.is_running() + + sandbox.pause() + assert not sandbox.is_running() + + resumed_sandbox = sandbox.connect() + assert sandbox.is_running() + assert resumed_sandbox.is_running() + assert resumed_sandbox.sandbox_id == sandbox.sandbox_id diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_api.py b/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_api.py new file mode 100644 index 0000000..1eef251 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_api.py @@ -0,0 +1,162 @@ +import pytest +from e2b import Sandbox + + +@pytest.mark.skip_debug() +def test_create_snapshot(sandbox: Sandbox): + snapshot = sandbox.create_snapshot() + + assert snapshot.snapshot_id + assert len(snapshot.snapshot_id) > 0 + + Sandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +def test_create_sandbox_from_snapshot(sandbox: Sandbox): + test_content = "content from original sandbox" + sandbox.files.write("/home/user/test.txt", test_content) + + snapshot = sandbox.create_snapshot() + + try: + new_sandbox = Sandbox.create(snapshot.snapshot_id) + + try: + content = new_sandbox.files.read("/home/user/test.txt") + assert content == test_content + finally: + new_sandbox.kill() + finally: + Sandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +def test_create_multiple_sandboxes_from_snapshot(sandbox: Sandbox): + test_content = "shared snapshot content" + sandbox.files.write("/home/user/shared.txt", test_content) + + snapshot = sandbox.create_snapshot() + + try: + sandbox1 = Sandbox.create(snapshot.snapshot_id) + sandbox2 = Sandbox.create(snapshot.snapshot_id) + + try: + content1 = sandbox1.files.read("/home/user/shared.txt") + content2 = sandbox2.files.read("/home/user/shared.txt") + + assert content1 == test_content + assert content2 == test_content + + sandbox1.files.write("/home/user/shared.txt", "modified in sandbox1") + + modified_content = sandbox1.files.read("/home/user/shared.txt") + unchanged_content = sandbox2.files.read("/home/user/shared.txt") + + assert modified_content == "modified in sandbox1" + assert unchanged_content == test_content + finally: + sandbox1.kill() + sandbox2.kill() + finally: + Sandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +def test_list_snapshots(sandbox: Sandbox): + snapshot = sandbox.create_snapshot() + + try: + paginator = Sandbox.list_snapshots() + assert paginator.has_next + + snapshots = paginator.next_items() + assert isinstance(snapshots, list) + + found = any(s.snapshot_id == snapshot.snapshot_id for s in snapshots) + assert found + finally: + Sandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +def test_list_snapshots_for_sandbox(sandbox: Sandbox): + snapshot = sandbox.create_snapshot() + + try: + paginator = Sandbox.list_snapshots(sandbox_id=sandbox.sandbox_id) + snapshots = paginator.next_items() + + found = any(s.snapshot_id == snapshot.snapshot_id for s in snapshots) + assert found + finally: + Sandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +def test_create_named_snapshot(sandbox: Sandbox, sandbox_test_id: str): + snapshot_name = f"snap-{sandbox_test_id}" + + snapshot = sandbox.create_snapshot(name=snapshot_name) + + try: + assert snapshot.snapshot_id + assert isinstance(snapshot.names, list) + assert len(snapshot.names) > 0 + assert any(snapshot_name in n for n in snapshot.names) + finally: + Sandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +def test_delete_snapshot(sandbox: Sandbox): + snapshot = sandbox.create_snapshot() + + deleted = Sandbox.delete_snapshot(snapshot.snapshot_id) + assert deleted is True + + deleted_again = Sandbox.delete_snapshot(snapshot.snapshot_id) + assert deleted_again is False + + +@pytest.mark.skip_debug() +def test_snapshot_preserves_filesystem(sandbox: Sandbox): + app_dir = "/home/user/app" + config_path = f"{app_dir}/config.json" + config_content = '{"env": "test"}' + data_path = f"{app_dir}/data.txt" + data_content = "important data" + + sandbox.files.make_dir(app_dir) + sandbox.files.write(config_path, config_content) + sandbox.files.write(data_path, data_content) + + snapshot = sandbox.create_snapshot() + + try: + new_sandbox = Sandbox.create(snapshot.snapshot_id) + + try: + dir_exists = new_sandbox.files.exists(app_dir) + assert dir_exists + + config = new_sandbox.files.read(config_path) + data = new_sandbox.files.read(data_path) + + assert config == config_content + assert data == data_content + finally: + new_sandbox.kill() + finally: + Sandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +def test_create_snapshot_class_method(sandbox: Sandbox): + snapshot = Sandbox.create_snapshot(sandbox.sandbox_id) + + assert snapshot.snapshot_id + assert len(snapshot.snapshot_id) > 0 + + Sandbox.delete_snapshot(snapshot.snapshot_id) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_filesystem_only.py b/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_filesystem_only.py new file mode 100644 index 0000000..0493b97 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_filesystem_only.py @@ -0,0 +1,29 @@ +import pytest +from e2b import Sandbox + + +@pytest.mark.skip_debug() +def test_pause_filesystem_only(sandbox: Sandbox): + # A marker on the persisted rootfs and the kernel boot id before pausing. + sandbox.files.write("/home/user/fs-only-marker.txt", "persisted") + boot_before = sandbox.files.read("/proc/sys/kernel/random/boot_id").strip() + + # Filesystem-only pause: only the rootfs is persisted, no memory snapshot. + assert sandbox.pause(keep_memory=False) + assert not sandbox.is_running() + + # Resuming a filesystem-only snapshot cold-boots (reboots) from the rootfs. + resumed = sandbox.connect() + assert resumed.is_running() + assert resumed.sandbox_id == sandbox.sandbox_id + + # connect() returns the same handle, and its credentials stay valid across + # the resume (the backend re-binds the same envd access token on the cold + # boot). The rootfs survives the reboot... + marker = resumed.files.read("/home/user/fs-only-marker.txt").strip() + assert marker == "persisted" + + # ...while a fresh kernel boot id proves the guest cold-booted rather than + # being restored from a memory snapshot. + boot_after = resumed.files.read("/proc/sys/kernel/random/boot_id").strip() + assert boot_after != boot_before diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_thread_local_clients.py b/packages/python-sdk/tests/sync/sandbox_sync/test_thread_local_clients.py new file mode 100644 index 0000000..d447087 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_thread_local_clients.py @@ -0,0 +1,300 @@ +from collections import Counter +from concurrent.futures import ThreadPoolExecutor +import threading +from types import SimpleNamespace +from unittest.mock import Mock, sentinel + +import httpx +from packaging.version import Version + +from e2b.connection_config import ConnectionConfig +from e2b.sandbox_sync.commands.command import Commands +from e2b.sandbox_sync.commands.pty import Pty +from e2b.sandbox_sync.filesystem import filesystem as filesystem_sync +from e2b.sandbox_sync.filesystem.filesystem import Filesystem +from e2b.sandbox_sync.filesystem.watch_handle import WatchHandle +import e2b.sandbox_sync.commands.command as command_sync +import e2b.sandbox_sync.commands.pty as pty_sync +import e2b.sandbox_sync.main as sandbox_sync_main + + +ENVD_API_URL = "https://sandbox.e2b.app" +ENVD_VERSION = Version("0.6.2") + + +def run_in_worker_thread(fn): + with ThreadPoolExecutor(max_workers=1) as executor: + return executor.submit(fn).result() + + +def fake_transport(): + return SimpleNamespace(pool=object()) + + +def test_sync_sandbox_envd_api_is_bound_per_calling_thread(monkeypatch, test_api_key): + config = ConnectionConfig(api_key=test_api_key) + main_api = Mock(spec=httpx.Client) + filesystem = SimpleNamespace(_envd_api=main_api) + + monkeypatch.setattr( + sandbox_sync_main, "Filesystem", lambda *args, **kwargs: filesystem + ) + monkeypatch.setattr(sandbox_sync_main, "Commands", lambda *args, **kwargs: object()) + monkeypatch.setattr(sandbox_sync_main, "Pty", lambda *args, **kwargs: object()) + monkeypatch.setattr(sandbox_sync_main, "Git", lambda *args, **kwargs: object()) + + sandbox = sandbox_sync_main.Sandbox( + sandbox_id="sbx-test", + sandbox_domain="e2b.app", + envd_version=ENVD_VERSION, + envd_access_token="tok", + traffic_access_token="tok", + connection_config=config, + ) + + assert sandbox._envd_api is main_api + assert sandbox._envd_api is sandbox.files._envd_api + assert not hasattr(sandbox, "_transport") + + +def test_sync_sandbox_clients_are_created_once_per_calling_thread( + monkeypatch, test_api_key +): + config = ConnectionConfig(api_key=test_api_key) + created = [] + transports = {} + lock = threading.Lock() + + def get_transport(*_args, **_kwargs): + thread_id = threading.get_ident() + with lock: + transport = transports.get(thread_id) + if transport is None: + transport = fake_transport() + transports[thread_id] = transport + return transport + + def record(kind): + with lock: + created.append((kind, threading.get_ident())) + + class FakeHttpClient: + def __init__(self, *args, **kwargs): + self.transport = kwargs["transport"] + record("http") + + class FakeFilesystemClient: + def __init__(self, *args, **kwargs): + self.pool = kwargs["pool"] + record("filesystem_rpc") + + class FakeProcessClient: + def __init__(self, *args, **kwargs): + self.pool = kwargs["pool"] + record("process_rpc") + + monkeypatch.setattr(filesystem_sync, "get_envd_transport", get_transport) + monkeypatch.setattr(command_sync, "get_envd_transport", get_transport) + monkeypatch.setattr(pty_sync, "get_envd_transport", get_transport) + monkeypatch.setattr(filesystem_sync.httpx, "Client", FakeHttpClient) + monkeypatch.setattr( + filesystem_sync.filesystem_connect, "FilesystemClient", FakeFilesystemClient + ) + monkeypatch.setattr( + command_sync.process_connect, "ProcessClient", FakeProcessClient + ) + monkeypatch.setattr(pty_sync.process_connect, "ProcessClient", FakeProcessClient) + + main_thread_id = threading.get_ident() + sandbox = sandbox_sync_main.Sandbox( + sandbox_id="sbx-test", + sandbox_domain="e2b.app", + envd_version=ENVD_VERSION, + envd_access_token="tok", + traffic_access_token="tok", + connection_config=config, + ) + + assert Counter(kind for kind, thread in created if thread == main_thread_id) == {} + + worker_count = 10 + barrier = threading.Barrier(worker_count + 1) + + def worker(): + barrier.wait() + envd_api = sandbox._envd_api + files_envd_api = sandbox.files._envd_api + files_rpc = sandbox.files._rpc + commands_rpc = sandbox.commands._rpc + pty_rpc = sandbox.pty._rpc + + return { + "thread": threading.get_ident(), + "same_envd_api": envd_api is files_envd_api, + "stable": ( + envd_api is sandbox._envd_api + and files_envd_api is sandbox.files._envd_api + and files_rpc is sandbox.files._rpc + and commands_rpc is sandbox.commands._rpc + and pty_rpc is sandbox.pty._rpc + ), + } + + with ThreadPoolExecutor(max_workers=worker_count) as executor: + futures = [executor.submit(worker) for _ in range(worker_count)] + barrier.wait() + results = [future.result() for future in futures] + + worker_threads = {result["thread"] for result in results} + worker_created = [ + (kind, thread) for kind, thread in created if thread in worker_threads + ] + + assert Counter(result["same_envd_api"] for result in results) == { + True: worker_count + } + assert Counter(result["stable"] for result in results) == {True: worker_count} + assert Counter(kind for kind, _thread in worker_created) == { + "http": worker_count, + "filesystem_rpc": worker_count, + "process_rpc": worker_count * 2, + } + + +def test_sync_filesystem_envd_clients_are_bound_per_calling_thread( + monkeypatch, test_api_key +): + config = ConnectionConfig(api_key=test_api_key) + main_thread_id = threading.get_ident() + main_transport = fake_transport() + worker_transport = fake_transport() + main_api = Mock(spec=httpx.Client) + worker_api = Mock(spec=httpx.Client) + main_rpc = sentinel.main_filesystem_rpc + worker_rpc = sentinel.worker_filesystem_rpc + + monkeypatch.setattr( + filesystem_sync, + "get_envd_transport", + lambda *_args, **_kwargs: main_transport + if threading.get_ident() == main_thread_id + else worker_transport, + ) + monkeypatch.setattr( + filesystem_sync.httpx, + "Client", + lambda *args, **kwargs: main_api + if kwargs["transport"] is main_transport + else worker_api, + ) + monkeypatch.setattr( + filesystem_sync.filesystem_connect, + "FilesystemClient", + lambda *args, **kwargs: main_rpc + if kwargs["pool"] is main_transport.pool + else worker_rpc, + ) + + fs = Filesystem( + ENVD_API_URL, + ENVD_VERSION, + config, + ) + + assert fs._envd_api is main_api + assert fs._rpc is main_rpc + + worker_api_result, worker_rpc_result = run_in_worker_thread( + lambda: (fs._envd_api, fs._rpc) + ) + assert worker_api_result is worker_api + assert worker_rpc_result is worker_rpc + assert fs._envd_api is main_api + assert fs._rpc is main_rpc + + +def test_sync_command_rpc_clients_are_bound_per_calling_thread( + monkeypatch, test_api_key +): + config = ConnectionConfig(api_key=test_api_key) + main_thread_id = threading.get_ident() + main_transport = fake_transport() + worker_transport = fake_transport() + main_rpc = sentinel.main_command_rpc + worker_rpc = sentinel.worker_command_rpc + + monkeypatch.setattr( + command_sync, + "get_envd_transport", + lambda *_args, **_kwargs: main_transport + if threading.get_ident() == main_thread_id + else worker_transport, + ) + monkeypatch.setattr( + command_sync.process_connect, + "ProcessClient", + lambda *args, **kwargs: main_rpc + if kwargs["pool"] is main_transport.pool + else worker_rpc, + ) + + commands = Commands(ENVD_API_URL, config, ENVD_VERSION) + + assert commands._rpc is main_rpc + worker_rpc_result = run_in_worker_thread(lambda: commands._rpc) + assert worker_rpc_result is worker_rpc + assert commands._rpc is main_rpc + + +def test_sync_pty_rpc_clients_are_bound_per_calling_thread(monkeypatch, test_api_key): + config = ConnectionConfig(api_key=test_api_key) + main_thread_id = threading.get_ident() + main_transport = fake_transport() + worker_transport = fake_transport() + main_rpc = sentinel.main_pty_rpc + worker_rpc = sentinel.worker_pty_rpc + + monkeypatch.setattr( + pty_sync, + "get_envd_transport", + lambda *_args, **_kwargs: main_transport + if threading.get_ident() == main_thread_id + else worker_transport, + ) + monkeypatch.setattr( + pty_sync.process_connect, + "ProcessClient", + lambda *args, **kwargs: main_rpc + if kwargs["pool"] is main_transport.pool + else worker_rpc, + ) + + pty = Pty(ENVD_API_URL, config, ENVD_VERSION) + + assert pty._rpc is main_rpc + worker_rpc_result = run_in_worker_thread(lambda: pty._rpc) + assert worker_rpc_result is worker_rpc + assert pty._rpc is main_rpc + + +def test_sync_watch_handle_uses_calling_thread_rpc(test_api_key): + class FakeRpc: + def __init__(self, name): + self.name = name + self.calls = [] + + def remove_watcher(self, request, **opts): + self.calls.append(("remove", request.watcher_id)) + + config = ConnectionConfig(api_key=test_api_key) + + main_rpc = FakeRpc("main") + worker_rpc = FakeRpc("worker") + handle = WatchHandle(lambda: main_rpc, "watcher-id", config, ENVD_VERSION) + + handle.stop() + assert main_rpc.calls == [("remove", "watcher-id")] + + handle = WatchHandle(lambda: worker_rpc, "watcher-id", config, ENVD_VERSION) + run_in_worker_thread(handle.stop) + assert worker_rpc.calls == [("remove", "watcher-id")] diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_timeout.py b/packages/python-sdk/tests/sync/sandbox_sync/test_timeout.py new file mode 100644 index 0000000..f175e7b --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_timeout.py @@ -0,0 +1,28 @@ +from time import sleep +from datetime import datetime + +import pytest + + +@pytest.mark.skip_debug() +def test_shorten_timeout(sandbox): + sandbox.set_timeout(5) + sleep(6) + + is_running = sandbox.is_running(request_timeout=5) + assert is_running is False + + +@pytest.mark.skip_debug() +def test_shorten_then_lengthen_timeout(sandbox): + sandbox.set_timeout(5) + sleep(1) + sandbox.set_timeout(10) + sleep(6) + sandbox.is_running() + + +@pytest.mark.skip_debug() +def test_get_timeout(sandbox): + info = sandbox.get_info() + assert isinstance(info.end_at, datetime) diff --git a/packages/python-sdk/tests/sync/template_sync/conftest.py b/packages/python-sdk/tests/sync/template_sync/conftest.py new file mode 100644 index 0000000..520f64f --- /dev/null +++ b/packages/python-sdk/tests/sync/template_sync/conftest.py @@ -0,0 +1,11 @@ +import os + +import pytest + +_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def pytest_collection_modifyitems(items): + for item in items: + if str(item.fspath).startswith(_DIR): + item.add_marker(pytest.mark.timeout(180)) diff --git a/packages/python-sdk/tests/sync/template_sync/methods/test_from_dockerfile.py b/packages/python-sdk/tests/sync/template_sync/methods/test_from_dockerfile.py new file mode 100644 index 0000000..a1db51a --- /dev/null +++ b/packages/python-sdk/tests/sync/template_sync/methods/test_from_dockerfile.py @@ -0,0 +1,133 @@ +import pytest + +from e2b import Template +from e2b.template.types import InstructionType + + +@pytest.mark.skip_debug() +def test_from_dockerfile(): + dockerfile = """FROM node:24 +WORKDIR /app +COPY package.json . +RUN npm install +ENTRYPOINT ["sleep", "20"]""" + + template = Template().from_dockerfile(dockerfile) + + # base image + assert template._template._base_image == "node:24" + + instructions = template._template._instructions + + # Docker defaults + assert instructions[1]["type"] == InstructionType.WORKDIR + assert instructions[1]["args"][0] == "/" + + # Instructions from Dockerfile + assert instructions[2]["type"] == InstructionType.WORKDIR + assert instructions[2]["args"][0] == "/app" + + assert instructions[3]["type"] == InstructionType.COPY + assert instructions[3]["args"][0] == "package.json" + assert instructions[3]["args"][1] == "." + + assert instructions[4]["type"] == InstructionType.RUN + assert instructions[4]["args"][0] == "npm install" + + # E2B defaults appended + assert instructions[5]["type"] == InstructionType.USER + assert instructions[5]["args"][0] == "user" + + # Start command + assert template._template._start_cmd == "sleep 20" + + +@pytest.mark.skip_debug() +def test_from_dockerfile_with_default_user_and_workdir(): + dockerfile = "FROM node:24" + + template = Template().from_dockerfile(dockerfile) + + assert template._template._instructions[-2]["type"] == InstructionType.USER + assert template._template._instructions[-2]["args"][0] == "user" + assert template._template._instructions[-1]["type"] == InstructionType.WORKDIR + assert template._template._instructions[-1]["args"][0] == "/home/user" + + +@pytest.mark.skip_debug() +def test_from_dockerfile_with_custom_user_and_workdir(): + dockerfile = "FROM node:24\nUSER mish\nWORKDIR /home/mish" + + template = Template().from_dockerfile(dockerfile) + + assert template._template._instructions[-2]["type"] == InstructionType.USER + assert template._template._instructions[-2]["args"][0] == "mish" + assert template._template._instructions[-1]["type"] == InstructionType.WORKDIR + assert template._template._instructions[-1]["args"][0] == "/home/mish" + + +@pytest.mark.skip_debug() +def test_from_dockerfile_with_multi_source_copy(): + dockerfile = """FROM node:24 +COPY file1.txt file2.txt file3.txt /dest/""" + + template = Template().from_dockerfile(dockerfile) + + instructions = template._template._instructions + + copy_instructions = [i for i in instructions if i["type"] == InstructionType.COPY] + + assert len(copy_instructions) == 3 + assert copy_instructions[0]["args"][0] == "file1.txt" + assert copy_instructions[0]["args"][1] == "/dest/" + assert copy_instructions[1]["args"][0] == "file2.txt" + assert copy_instructions[1]["args"][1] == "/dest/" + assert copy_instructions[2]["args"][0] == "file3.txt" + assert copy_instructions[2]["args"][1] == "/dest/" + + +@pytest.mark.skip_debug() +def test_from_dockerfile_with_multi_source_copy_chown(): + dockerfile = """FROM node:24 +COPY --chown=myuser:mygroup pkg.json pkg-lock.json /app/""" + + template = Template().from_dockerfile(dockerfile) + + instructions = template._template._instructions + + copy_instructions = [i for i in instructions if i["type"] == InstructionType.COPY] + + assert len(copy_instructions) == 2 + assert copy_instructions[0]["args"][0] == "pkg.json" + assert copy_instructions[0]["args"][1] == "/app/" + assert copy_instructions[0]["args"][2] == "myuser:mygroup" + assert copy_instructions[1]["args"][0] == "pkg-lock.json" + assert copy_instructions[1]["args"][1] == "/app/" + assert copy_instructions[1]["args"][2] == "myuser:mygroup" + + +@pytest.mark.skip_debug() +def test_from_dockerfile_with_copy_chown(): + dockerfile = """FROM node:24 +COPY --chown=myuser:mygroup app.js /app/ +COPY --chown=anotheruser config.json /config/""" + + template = Template().from_dockerfile(dockerfile) + + instructions = template._template._instructions + + # First COPY instruction (after initial USER root and WORKDIR /) + copy_instruction1 = instructions[2] + assert copy_instruction1["type"] == InstructionType.COPY + assert copy_instruction1["args"][0] == "app.js" + assert copy_instruction1["args"][1] == "/app/" + assert copy_instruction1["args"][2] == "myuser:mygroup" # user from --chown + + # Second COPY instruction + copy_instruction2 = instructions[3] + assert copy_instruction2["type"] == InstructionType.COPY + assert copy_instruction2["args"][0] == "config.json" + assert copy_instruction2["args"][1] == "/config/" + assert ( + copy_instruction2["args"][2] == "anotheruser" + ) # user from --chown (without group) diff --git a/packages/python-sdk/tests/sync/template_sync/methods/test_make_symlink.py b/packages/python-sdk/tests/sync/template_sync/methods/test_make_symlink.py new file mode 100644 index 0000000..0371099 --- /dev/null +++ b/packages/python-sdk/tests/sync/template_sync/methods/test_make_symlink.py @@ -0,0 +1,32 @@ +import pytest + +from e2b import Template + + +@pytest.mark.skip_debug() +def test_make_symlink(build): + template = ( + Template() + .from_image("ubuntu:22.04") + .skip_cache() + .make_symlink(".bashrc", ".bashrc.local") + .run_cmd('test "$(readlink .bashrc.local)" = ".bashrc"') + ) + + build(template) + + +@pytest.mark.skip_debug() +def test_make_symlink_force(build): + template = ( + Template() + .from_image("ubuntu:22.04") + .make_symlink(".bashrc", ".bashrc.local") + .skip_cache() + .make_symlink( + ".bashrc", ".bashrc.local", force=True + ) # Overwrite existing symlink + .run_cmd('test "$(readlink .bashrc.local)" = ".bashrc"') + ) + + build(template) diff --git a/packages/python-sdk/tests/sync/template_sync/methods/test_run_cmd.py b/packages/python-sdk/tests/sync/template_sync/methods/test_run_cmd.py new file mode 100644 index 0000000..2114026 --- /dev/null +++ b/packages/python-sdk/tests/sync/template_sync/methods/test_run_cmd.py @@ -0,0 +1,40 @@ +import pytest + +from e2b import Template + + +@pytest.mark.skip_debug() +def test_run_command(build): + template = Template().from_image("ubuntu:22.04").skip_cache().run_cmd("ls -l") + + build(template) + + +@pytest.mark.skip_debug() +def test_run_command_as_different_user(build): + template = ( + Template() + .from_image("ubuntu:22.04") + .skip_cache() + .run_cmd('test "$(whoami)" = "root"', user="root") + ) + + build(template) + + +@pytest.mark.skip_debug() +def test_run_command_as_user_that_does_not_exist(build): + template = ( + Template() + .from_image("ubuntu:22.04") + .skip_cache() + .run_cmd("whoami", user="root123") + ) + + with pytest.raises(Exception) as exc_info: + build(template) + + assert ( + "failed to run command 'whoami': command failed: unauthenticated: invalid username: 'root123'" + in str(exc_info.value) + ) diff --git a/packages/python-sdk/tests/sync/template_sync/methods/test_to_dockerfile.py b/packages/python-sdk/tests/sync/template_sync/methods/test_to_dockerfile.py new file mode 100644 index 0000000..ed1b149 --- /dev/null +++ b/packages/python-sdk/tests/sync/template_sync/methods/test_to_dockerfile.py @@ -0,0 +1,57 @@ +import pytest + +from e2b import Template + + +@pytest.mark.skip_debug() +def test_to_dockerfile(): + template = ( + Template() + .from_ubuntu_image("24.04") + .copy("README.md", "/app/README.md") + .run_cmd('echo "Hello, World!"') + ) + + dockerfile = Template.to_dockerfile(template) + + expected_dockerfile = """FROM ubuntu:24.04 +COPY README.md /app/README.md +RUN echo "Hello, World!" +""" + assert dockerfile == expected_dockerfile + + +@pytest.mark.skip_debug() +def test_to_dockerfile_with_options(): + template = ( + Template() + .from_ubuntu_image("24.04") + .copy("README.md", "/app/README.md", user="root") + .run_cmd('echo "Hello, World!"', user="root") + ) + + dockerfile = Template.to_dockerfile(template) + + expected_dockerfile = """FROM ubuntu:24.04 +COPY README.md /app/README.md +RUN echo "Hello, World!" +""" + assert dockerfile == expected_dockerfile + + +@pytest.mark.skip_debug() +def test_to_dockerfile_with_env_instructions(): + template = ( + Template() + .from_ubuntu_image("24.04") + .set_envs({"NODE_ENV": "production", "PORT": "8080"}) + .set_envs({"DEBUG": "false"}) + ) + + dockerfile = Template.to_dockerfile(template) + + expected_dockerfile = """FROM ubuntu:24.04 +ENV NODE_ENV=production PORT=8080 +ENV DEBUG=false +""" + assert dockerfile == expected_dockerfile diff --git a/packages/python-sdk/tests/sync/template_sync/test_background_build.py b/packages/python-sdk/tests/sync/template_sync/test_background_build.py new file mode 100644 index 0000000..f5d41db --- /dev/null +++ b/packages/python-sdk/tests/sync/template_sync/test_background_build.py @@ -0,0 +1,34 @@ +import uuid + +import pytest + +from e2b import Template, wait_for_timeout + + +@pytest.mark.skip_debug() +@pytest.mark.timeout(10) +def test_build_in_background_should_start_build_and_return_info(): + """Test that build_in_background returns immediately without waiting for build to complete.""" + template = ( + Template() + .from_image("ubuntu:22.04") + .skip_cache() + .run_cmd("sleep 5") # Add a delay to ensure build takes time + .set_start_cmd('echo "Hello"', wait_for_timeout(10_000)) + ) + + name = f"e2b-test:v1-{uuid.uuid4()}" + + build_info = Template.build_in_background( + template, + name, + cpu_count=1, + memory_mb=1024, + ) + + # Should return quickly (within a few seconds), not wait for the full build + assert build_info is not None + + # Verify the build is actually running + status = Template.get_build_status(build_info) + assert status.status.value == "building" diff --git a/packages/python-sdk/tests/sync/template_sync/test_build.py b/packages/python-sdk/tests/sync/template_sync/test_build.py new file mode 100644 index 0000000..423e809 --- /dev/null +++ b/packages/python-sdk/tests/sync/template_sync/test_build.py @@ -0,0 +1,92 @@ +import tempfile + +import pytest +import os +import shutil + +from e2b import Template, wait_for_timeout, default_build_logger + + +@pytest.fixture(scope="module") +def setup_test_folder(): + test_dir = tempfile.mkdtemp(prefix="python_sync_test_") + folder_path = os.path.join(test_dir, "folder") + + os.makedirs(folder_path, exist_ok=True) + with open(os.path.join(folder_path, "test.txt"), "w") as f: + f.write("This is a test file.") + + # Create relative symlink + symlink_path = os.path.join(folder_path, "symlink.txt") + if os.path.exists(symlink_path): + os.remove(symlink_path) + os.symlink("test.txt", symlink_path) + + # Create absolute symlink + symlink2_path = os.path.join(folder_path, "symlink2.txt") + if os.path.exists(symlink2_path): + os.remove(symlink2_path) + os.symlink(os.path.join(folder_path, "test.txt"), symlink2_path) + + # Create a symlink to a file that does not exist + symlink3_path = os.path.join(folder_path, "symlink3.txt") + if os.path.exists(symlink3_path): + os.remove(symlink3_path) + os.symlink("12345test.txt", symlink3_path) + + yield test_dir + + # Cleanup + shutil.rmtree(test_dir, ignore_errors=True) + + +@pytest.mark.skip_debug() +def test_build_template(build, setup_test_folder): + template = ( + Template(file_context_path=setup_test_folder) + # using base image to avoid re-building ubuntu:22.04 image + .from_base_image() + .copy("folder/*", "folder", force_upload=True) + .run_cmd("cat folder/test.txt") + .set_workdir("/app") + .set_start_cmd("echo 'Hello, world!'", wait_for_timeout(10_000)) + ) + + build(template, skip_cache=True, on_build_logs=default_build_logger()) + + +@pytest.mark.skip_debug() +def test_build_template_from_base_template(build): + template = Template().from_template("base") + build(template, skip_cache=True, on_build_logs=default_build_logger()) + + +@pytest.mark.skip_debug() +def test_build_template_with_symlinks(build, setup_test_folder): + template = ( + Template(file_context_path=setup_test_folder) + .from_image("ubuntu:22.04") + .skip_cache() + .copy("folder/*", "folder", force_upload=True) + .run_cmd("cat folder/symlink.txt") + ) + + build(template) + + +@pytest.mark.skip_debug() +def test_build_template_with_resolve_symlinks(build, setup_test_folder): + template = ( + Template(file_context_path=setup_test_folder) + .from_image("ubuntu:22.04") + .skip_cache() + .copy( + "folder/symlink.txt", + "folder/symlink.txt", + force_upload=True, + resolve_symlinks=True, + ) + .run_cmd("cat folder/symlink.txt") + ) + + build(template) diff --git a/packages/python-sdk/tests/sync/template_sync/test_exists.py b/packages/python-sdk/tests/sync/template_sync/test_exists.py new file mode 100644 index 0000000..641b58b --- /dev/null +++ b/packages/python-sdk/tests/sync/template_sync/test_exists.py @@ -0,0 +1,20 @@ +import uuid + +import pytest + +from e2b import Template + + +@pytest.mark.skip_debug() +def test_check_base_template_name_exists(): + """Test that the base template name exists.""" + exists = Template.exists("base") + assert exists is True + + +@pytest.mark.skip_debug() +def test_check_non_existing_name(): + """Test that a non-existing name returns False.""" + non_existing_name = f"nonexistent-{uuid.uuid4()}" + exists = Template.exists(non_existing_name) + assert exists is False diff --git a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py new file mode 100644 index 0000000..8d1fa71 --- /dev/null +++ b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py @@ -0,0 +1,422 @@ +import traceback +from types import SimpleNamespace +from typing import List, Optional +from uuid import uuid4 + +import pytest +import linecache + +from e2b import Template, CopyItem, wait_for_timeout +from e2b.template.types import TemplateBuildStatus +import e2b.template_sync.main as template_sync_main +import e2b.template_sync.build_api as build_api_mod + +non_existent_path = "nonexistent/path" + +# map template alias -> failed step index +failure_map: dict[str, Optional[int]] = { + "from_image": 0, + "from_template": 0, + "from_dockerfile": 0, + "from_image_registry": 0, + "from_aws_registry": 0, + "from_gcp_registry": 0, + "copy": None, + "copy_items": None, + # multi-source copy produces two COPY instructions (steps 1 and 2), + # the run_cmd after it is step 3 + "multi_source_copy_second_source": 2, + "multi_source_copy_next_step": 3, + "copy_items_second_item": 2, + "copy_items_next_step": 3, + "remove": 1, + "rename": 1, + "make_dir": 1, + "make_symlink": 1, + "run_cmd": 1, + "set_workdir": 1, + "set_user": 1, + "pip_install": 1, + "npm_install": 1, + "apt_install": 1, + "git_clone": 1, + "set_start_cmd": 1, + "add_mcp_server": None, + "beta_dev_container_prebuild": 1, + "beta_set_dev_container_start": 1, +} + + +@pytest.fixture(autouse=True) +def mock_template_build(monkeypatch): + def mock_request_build( + client, name: str, tags: Optional[List[str]], cpu_count: int, memory_mb: int + ): + return SimpleNamespace(template_id=name, build_id=str(uuid4()), tags=tags or []) + + def mock_trigger_build(client, template_id: str, build_id: str, template): + return None + + def mock_get_file_upload_link( + client, template_id: str, files_hash: str, stack_trace=None + ): + return SimpleNamespace(present=True, url=None) + + def mock_get_build_status( + client, template_id: str, build_id: str, logs_offset: int + ): + step = failure_map[template_id] + reason = SimpleNamespace( + message="Mocked API build error", + log_entries=[], + step=str(step) if step is not None else None, + ) + return SimpleNamespace( + status=TemplateBuildStatus.ERROR, + log_entries=[], + reason=reason, + ) + + monkeypatch.setattr(template_sync_main, "request_build", mock_request_build) + monkeypatch.setattr(template_sync_main, "trigger_build", mock_trigger_build) + monkeypatch.setattr( + template_sync_main, "get_file_upload_link", mock_get_file_upload_link + ) + monkeypatch.setattr(build_api_mod, "get_build_status", mock_get_build_status) + + +def _expect_to_throw_and_check_trace(func, expected_method: str): + try: + func() + assert False, "Expected Template.build to raise an exception" + except Exception as e: # noqa: BLE001 - we want to assert on the traceback regardless of type + tb = e.__traceback__ + saw_this_file = False + saw_expected_method = False + while tb is not None: + traceback_file = tb.tb_frame.f_code.co_filename + if traceback_file == __file__: + saw_this_file = True + caller_line = linecache.getline(traceback_file, tb.tb_lineno) + if caller_line and f".{expected_method}(" in caller_line: + saw_expected_method = True + break + tb = tb.tb_next + assert saw_this_file, traceback.format_exc() + assert saw_expected_method, traceback.format_exc() + + +@pytest.mark.skip_debug() +def test_traces_on_from_image(build): + template = Template() + template = template.from_image("e2b.dev/this-image-does-not-exist") + _expect_to_throw_and_check_trace( + lambda: build(template, name="from_image", skip_cache=True), "from_image" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_from_template(build): + template = Template().from_template("this-template-does-not-exist") + _expect_to_throw_and_check_trace( + lambda: build(template, name="from_template", skip_cache=True), "from_template" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_from_dockerfile(build): + template = Template() + template = template.from_dockerfile("FROM ubuntu:22.04\nRUN nonexistent") + _expect_to_throw_and_check_trace( + lambda: build(template, name="from_dockerfile", skip_cache=True), + "from_dockerfile", + ) + + +@pytest.mark.skip_debug() +def test_traces_on_from_image_registry(build): + template = Template() + template = template.from_image( + "registry.example.com/nonexistent:latest", + username="test", + password="test", + ) + _expect_to_throw_and_check_trace( + lambda: build(template, name="from_image_registry", skip_cache=True), + "from_image", + ) + + +@pytest.mark.skip_debug() +def test_traces_on_from_image_credentials(): + _expect_to_throw_and_check_trace( + lambda: Template().from_image("ubuntu:22.04", username="user"), + "from_image", + ) + + +@pytest.mark.skip_debug() +def test_traces_on_from_aws_registry(build): + template = Template() + template = template.from_aws_registry( + "123456789.dkr.ecr.us-east-1.amazonaws.com/nonexistent:latest", + access_key_id="test", + secret_access_key="test", + region="us-east-1", + ) + _expect_to_throw_and_check_trace( + lambda: build(template, name="from_aws_registry"), "from_aws_registry" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_from_gcp_registry(build): + template = Template() + template = template.from_gcp_registry( + "gcr.io/nonexistent-project/nonexistent:latest", + service_account_json={ + "type": "service_account", + }, + ) + _expect_to_throw_and_check_trace( + lambda: build(template, name="from_gcp_registry"), "from_gcp_registry" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_copy(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().copy(non_existent_path, non_existent_path) + _expect_to_throw_and_check_trace(lambda: build(template, name="copy"), "copy") + + +@pytest.mark.skip_debug() +def test_traces_on_copyItems(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().copy_items( + [CopyItem(src=non_existent_path, dest=non_existent_path)] + ) + _expect_to_throw_and_check_trace( + lambda: build(template, name="copy_items"), "copy_items" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_second_source_of_multi_source_copy(build): + template = Template() + template = template.from_base_image() + template = template.copy(["test_stacktrace.py", "test_tags.py"], ".") + _expect_to_throw_and_check_trace( + lambda: build(template, name="multi_source_copy_second_source"), "copy" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_step_after_multi_source_copy(build): + template = Template() + template = template.from_base_image() + template = template.copy(["test_stacktrace.py", "test_tags.py"], ".") + template = template.run_cmd(f"cat {non_existent_path}") + _expect_to_throw_and_check_trace( + lambda: build(template, name="multi_source_copy_next_step"), "run_cmd" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_second_item_of_copy_items(build): + template = Template() + template = template.from_base_image() + template = template.copy_items( + [ + CopyItem(src="test_stacktrace.py", dest="."), + CopyItem(src="test_tags.py", dest="."), + ] + ) + _expect_to_throw_and_check_trace( + lambda: build(template, name="copy_items_second_item"), "copy_items" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_step_after_copy_items(build): + template = Template() + template = template.from_base_image() + template = template.copy_items( + [ + CopyItem(src="test_stacktrace.py", dest="."), + CopyItem(src="test_tags.py", dest="."), + ] + ) + template = template.run_cmd(f"cat {non_existent_path}") + _expect_to_throw_and_check_trace( + lambda: build(template, name="copy_items_next_step"), "run_cmd" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_copy_absolute_path(): + _expect_to_throw_and_check_trace( + lambda: Template().from_base_image().copy("/absolute/path", "/absolute/path"), + "copy", + ) + + +@pytest.mark.skip_debug() +def test_traces_on_copyItems_absolute_path(): + _expect_to_throw_and_check_trace( + lambda: ( + Template() + .from_base_image() + .copy_items([CopyItem(src="/absolute/path", dest="/absolute/path")]) + ), + "copy_items", + ) + + +@pytest.mark.skip_debug() +def test_traces_on_remove(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().remove(non_existent_path) + _expect_to_throw_and_check_trace(lambda: build(template, name="remove"), "remove") + + +@pytest.mark.skip_debug() +def test_traces_on_rename(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().rename(non_existent_path, "/tmp/dest.txt") + _expect_to_throw_and_check_trace(lambda: build(template, name="rename"), "rename") + + +@pytest.mark.skip_debug() +def test_traces_on_make_dir(build): + template = Template() + template = template.from_base_image() + template = template.set_user("root").skip_cache().make_dir("/root/.bashrc") + _expect_to_throw_and_check_trace( + lambda: build(template, name="make_dir"), "make_dir" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_make_symlink(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().make_symlink(".bashrc", ".bashrc") + _expect_to_throw_and_check_trace( + lambda: build(template, name="make_symlink"), "make_symlink" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_run_cmd(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().run_cmd(f"cat {non_existent_path}") + _expect_to_throw_and_check_trace(lambda: build(template, name="run_cmd"), "run_cmd") + + +@pytest.mark.skip_debug() +def test_traces_on_set_workdir(build): + template = Template() + template = template.from_base_image() + template = template.set_user("root").skip_cache().set_workdir("/root/.bashrc") + _expect_to_throw_and_check_trace( + lambda: build(template, name="set_workdir"), "set_workdir" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_set_user(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().set_user("; exit 1") + _expect_to_throw_and_check_trace( + lambda: build(template, name="set_user"), "set_user" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_pip_install(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().pip_install("nonexistent-package") + _expect_to_throw_and_check_trace( + lambda: build(template, name="pip_install"), "pip_install" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_npm_install(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().npm_install("nonexistent-package") + _expect_to_throw_and_check_trace( + lambda: build(template, name="npm_install"), "npm_install" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_apt_install(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().apt_install("nonexistent-package") + _expect_to_throw_and_check_trace( + lambda: build(template, name="apt_install"), "apt_install" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_git_clone(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().git_clone("https://github.com/repo.git") + _expect_to_throw_and_check_trace( + lambda: build(template, name="git_clone"), "git_clone" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_set_start_cmd(build): + template = Template() + template = template.from_base_image() + template = template.set_start_cmd( + f"./{non_existent_path}", wait_for_timeout(10_000) + ) + _expect_to_throw_and_check_trace( + lambda: build(template, name="set_start_cmd"), "set_start_cmd" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_add_mcp_server(): + # needs mcp-gateway as base template, without it no mcp servers can be added + _expect_to_throw_and_check_trace( + lambda: Template().from_base_image().skip_cache().add_mcp_server("exa"), + "add_mcp_server", + ) + + +@pytest.mark.skip_debug() +def test_traces_on_dev_container_prebuild(build): + template = Template() + template = template.from_template("devcontainer") + template = template.skip_cache().beta_dev_container_prebuild(non_existent_path) + _expect_to_throw_and_check_trace( + lambda: build(template, name="beta_dev_container_prebuild"), + "beta_dev_container_prebuild", + ) + + +@pytest.mark.skip_debug() +def test_traces_on_set_dev_container_start(build): + template = Template() + template = template.from_template("devcontainer") + template = template.beta_set_dev_container_start(non_existent_path) + _expect_to_throw_and_check_trace( + lambda: build(template, name="beta_set_dev_container_start"), + "beta_set_dev_container_start", + ) diff --git a/packages/python-sdk/tests/sync/template_sync/test_tags.py b/packages/python-sdk/tests/sync/template_sync/test_tags.py new file mode 100644 index 0000000..9caaca3 --- /dev/null +++ b/packages/python-sdk/tests/sync/template_sync/test_tags.py @@ -0,0 +1,228 @@ +import uuid +from datetime import datetime, timezone +from unittest.mock import Mock + +import pytest + +from e2b import TemplateTag, TemplateTagInfo, Template +from e2b.exceptions import TemplateException +import e2b.template_sync.main as template_sync_main + + +class TestAssignTags: + """Tests for Template.assign_tags method.""" + + def test_assign_single_tag(self, monkeypatch): + """Test assigning a single tag to a template.""" + mock_assign_tags = Mock( + return_value=TemplateTagInfo( + build_id="00000000-0000-0000-0000-000000000000", + tags=["production"], + ) + ) + + monkeypatch.setattr( + template_sync_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr(template_sync_main, "assign_tags", mock_assign_tags) + + result = Template.assign_tags("my-template:v1.0", "production") + + assert result.build_id == "00000000-0000-0000-0000-000000000000" + assert "production" in result.tags + mock_assign_tags.assert_called_once() + _, target, tags = mock_assign_tags.call_args[0] + assert target == "my-template:v1.0" + assert tags == ["production"] + + def test_assign_multiple_tags(self, monkeypatch): + """Test assigning multiple tags to a template.""" + mock_assign_tags = Mock( + return_value=TemplateTagInfo( + build_id="00000000-0000-0000-0000-000000000000", + tags=["production", "stable"], + ) + ) + + monkeypatch.setattr( + template_sync_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr(template_sync_main, "assign_tags", mock_assign_tags) + + result = Template.assign_tags("my-template:v1.0", ["production", "stable"]) + + assert result.build_id == "00000000-0000-0000-0000-000000000000" + assert "production" in result.tags + assert "stable" in result.tags + mock_assign_tags.assert_called_once() + _, _, tags = mock_assign_tags.call_args[0] + assert tags == ["production", "stable"] + + +class TestRemoveTags: + """Tests for Template.remove_tags method.""" + + def test_remove_single_tag(self, monkeypatch): + """Test deleting a single tag from a template.""" + mock_remove_tags = Mock(return_value=None) + + monkeypatch.setattr( + template_sync_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr(template_sync_main, "remove_tags", mock_remove_tags) + + Template.remove_tags("my-template", "production") + + mock_remove_tags.assert_called_once() + _, name, tags = mock_remove_tags.call_args[0] + assert name == "my-template" + assert tags == ["production"] + + def test_remove_multiple_tags(self, monkeypatch): + """Test deleting multiple tags from a template.""" + mock_remove_tags = Mock(return_value=None) + + monkeypatch.setattr( + template_sync_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr(template_sync_main, "remove_tags", mock_remove_tags) + + Template.remove_tags("my-template", ["production", "staging"]) + + mock_remove_tags.assert_called_once() + _, name, tags = mock_remove_tags.call_args[0] + assert name == "my-template" + assert tags == ["production", "staging"] + + def test_remove_tags_error(self, monkeypatch): + """Test that remove_tags raises an error for nonexistent template.""" + mock_remove_tags = Mock(side_effect=TemplateException("Template not found")) + + monkeypatch.setattr( + template_sync_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr(template_sync_main, "remove_tags", mock_remove_tags) + + with pytest.raises(TemplateException): + Template.remove_tags("nonexistent", ["tag"]) + + +class TestGetTags: + """Tests for Template.get_tags method.""" + + def test_get_tags(self, monkeypatch): + """Test getting tags for a template.""" + mock_get_template_tags = Mock( + return_value=[ + TemplateTag( + tag="v1.0", + build_id="00000000-0000-0000-0000-000000000000", + created_at=datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + ), + TemplateTag( + tag="latest", + build_id="11111111-1111-1111-1111-111111111111", + created_at=datetime(2024, 1, 16, 12, 0, 0, tzinfo=timezone.utc), + ), + ] + ) + + monkeypatch.setattr( + template_sync_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr( + template_sync_main, "get_template_tags", mock_get_template_tags + ) + + result = Template.get_tags("my-template") + + assert len(result) == 2 + assert result[0].tag == "v1.0" + assert result[0].build_id == "00000000-0000-0000-0000-000000000000" + assert isinstance(result[0].created_at, datetime) + assert result[1].tag == "latest" + mock_get_template_tags.assert_called_once() + + def test_get_tags_error(self, monkeypatch): + """Test that get_tags raises an error for nonexistent template.""" + mock_get_template_tags = Mock( + side_effect=TemplateException("Template not found") + ) + + monkeypatch.setattr( + template_sync_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr( + template_sync_main, "get_template_tags", mock_get_template_tags + ) + + with pytest.raises(TemplateException): + Template.get_tags("nonexistent") + + +# Integration tests +class TestTagsIntegration: + """Integration tests for Template tags functionality.""" + + @pytest.mark.skip_debug() + def test_build_template_with_tags_assign_and_delete(self, build): + """Test building a template with tags, assigning new tags, and deleting.""" + template_name = "e2b-tags-test" + initial_tag = f"{template_name}:v1-{uuid.uuid4().hex}" + + # Build a template with initial tag + template = Template().from_base_image() + build_info = build(template, name=initial_tag) + + assert build_info.build_id + assert build_info.template_id + + # Assign additional tags + tag_info = Template.assign_tags(initial_tag, ["production", "latest"]) + + assert tag_info.build_id + # API returns just the tag portion, not the full alias:tag + assert "production" in tag_info.tags + assert "latest" in tag_info.tags + + @pytest.mark.skip_debug() + def test_assign_single_tag_to_existing_template(self, build): + """Test assigning a single tag (not array) to an existing template.""" + template_name = "e2b-tags-test" + initial_tag = f"{template_name}:v1-{uuid.uuid4().hex}" + + template = Template().from_base_image() + build(template, name=initial_tag) + + # Assign single tag (not array) + tag_info = Template.assign_tags(initial_tag, "stable") + + assert tag_info.build_id + # API returns just the tag portion, not the full alias:tag + assert "stable" in tag_info.tags + + @pytest.mark.skip_debug() + def test_rejects_invalid_tag_format_missing_alias(self, build): + """Test that tag without alias (starts with colon) is rejected.""" + template_name = "e2b-tags-test" + initial_tag = f"{template_name}:v1-{uuid.uuid4().hex}" + + template = Template().from_base_image() + build(template, name=initial_tag) + + # Tag without alias (starts with colon) should be rejected + with pytest.raises(Exception): + Template.assign_tags(initial_tag, ":invalid-tag") + + @pytest.mark.skip_debug() + def test_rejects_invalid_tag_format_missing_tag(self, build): + """Test that tag without tag portion (ends with colon) is rejected.""" + template_name = "e2b-tags-test" + initial_tag = f"{template_name}:v1-{uuid.uuid4().hex}" + + template = Template().from_base_image() + build(template, name=initial_tag) + + # Tag without tag portion (ends with colon) should be rejected + with pytest.raises(Exception): + Template.assign_tags(initial_tag, f"{template_name}:") diff --git a/packages/python-sdk/tests/sync/template_sync/test_upload_file.py b/packages/python-sdk/tests/sync/template_sync/test_upload_file.py new file mode 100644 index 0000000..74b65fa --- /dev/null +++ b/packages/python-sdk/tests/sync/template_sync/test_upload_file.py @@ -0,0 +1,193 @@ +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from unittest import mock + +import httpx + +from e2b.api.client.client import AuthenticatedClient +from e2b.template import utils as template_utils +from e2b.template.consts import FILE_UPLOAD_TIMEOUT_SECONDS +from e2b.template_sync.build_api import upload_file + + +# Regression test for e2b-dev/e2b#1243 — upload_file must set Content-Length +# and must not fall back to Transfer-Encoding: chunked. S3 presigned PUT URLs +# reject chunked encoding with 501 NotImplemented. The archive is streamed +# from a temporary file on disk with a known Content-Length instead of being +# buffered in memory; this test guards that contract. + + +def _make_server(): + state = {"headers": None, "body_length": 0} + + class Handler(BaseHTTPRequestHandler): + def do_PUT(self): + state["headers"] = dict(self.headers) + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) if length else b"" + state["body_length"] = len(body) + self.send_response(200) + self.end_headers() + + def log_message(self, *args, **kwargs): + return + + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread, state + + +def test_upload_file_sets_content_length_and_no_chunked_encoding(tmp_path): + (tmp_path / "hello.txt").write_text("hello world") + + server, thread, state = _make_server() + host, port = server.server_address + url = f"http://{host}:{port}/upload" + + try: + + class UploadClient(AuthenticatedClient): + def get_httpx_client(self): + raise AssertionError("signed uploads should not use the API client") + + client = UploadClient(base_url="http://test", token="test") + upload_file( + api_client=client, + file_name="*.txt", + context_path=str(tmp_path), + url=url, + ignore_patterns=[], + resolve_symlinks=False, + gzip=True, + stack_trace=None, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert state["headers"] is not None + content_length = state["headers"].get("Content-Length") + assert content_length is not None + assert int(content_length) > 0 + assert int(content_length) == state["body_length"] + + transfer_encoding = state["headers"].get("Transfer-Encoding") + if transfer_encoding is not None: + assert "chunked" not in transfer_encoding.lower() + assert "Authorization" not in state["headers"] + + +def _capture_upload_timeout(tmp_path, request_timeout=None): + """Run upload_file against a local server, capturing the httpx timeout.""" + (tmp_path / "hello.txt").write_text("hello world") + + server, thread, state = _make_server() + host, port = server.server_address + url = f"http://{host}:{port}/upload" + + captured = {} + real_client = httpx.Client + + def spy_client(*args, **kwargs): + captured["timeout"] = kwargs.get("timeout") + return real_client(*args, **kwargs) + + try: + client = AuthenticatedClient(base_url="http://test", token="test") + kwargs = {} + if request_timeout is not None: + kwargs["request_timeout"] = request_timeout + with mock.patch( + "e2b.template_sync.build_api.httpx.Client", side_effect=spy_client + ): + upload_file( + api_client=client, + file_name="*.txt", + context_path=str(tmp_path), + url=url, + ignore_patterns=[], + resolve_symlinks=False, + gzip=True, + stack_trace=None, + **kwargs, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + return captured["timeout"] + + +def test_upload_file_defaults_to_one_hour_timeout(tmp_path): + # Uploads of large archives need far longer than the 60s general API + # timeout, so the default upload timeout is 1 hour (matches the JS SDK). + timeout = _capture_upload_timeout(tmp_path) + assert timeout == httpx.Timeout(FILE_UPLOAD_TIMEOUT_SECONDS) + + +def test_upload_file_honors_explicit_request_timeout(tmp_path): + # An explicitly set request_timeout overrides the 1-hour upload default. + timeout = _capture_upload_timeout(tmp_path, request_timeout=5.0) + assert timeout == httpx.Timeout(5.0) + + +def test_upload_file_ignores_post_upload_close_failure(tmp_path): + # Regression test: once S3 has accepted the archive, closing the spooled + # temp file in the `finally` block can raise. That failure must not be + # wrapped as a FileUploadException — the upload already succeeded. + (tmp_path / "hello.txt").write_text("hello world") + + server, thread, state = _make_server() + host, port = server.server_address + url = f"http://{host}:{port}/upload" + + real_tar_file_stream = template_utils.tar_file_stream + + class _FailingCloseFile: + # Proxies a real spooled temp file but raises on close(). The + # underlying file object is a C type whose `close` attribute can't + # be reassigned, so we wrap it instead. + def __init__(self, inner): + self._inner = inner + + def __getattr__(self, name): + return getattr(self._inner, name) + + def __iter__(self): + return iter(self._inner) + + def close(self): + # Run the real close so we don't leak the temp file, then + # simulate a close failure surfacing from the `finally`. + self._inner.close() + raise OSError("close failed") + + def failing_close_stream(*args, **kwargs): + return _FailingCloseFile(real_tar_file_stream(*args, **kwargs)) + + try: + client = AuthenticatedClient(base_url="http://test", token="test") + with mock.patch( + "e2b.template_sync.build_api.tar_file_stream", + side_effect=failing_close_stream, + ): + # Must not raise despite close() failing after a 200 response. + upload_file( + api_client=client, + file_name="*.txt", + context_path=str(tmp_path), + url=url, + ignore_patterns=[], + resolve_symlinks=False, + gzip=True, + stack_trace=None, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert state["headers"] is not None diff --git a/packages/python-sdk/tests/sync/volume_sync/test_file.py b/packages/python-sdk/tests/sync/volume_sync/test_file.py new file mode 100644 index 0000000..eb2dfa0 --- /dev/null +++ b/packages/python-sdk/tests/sync/volume_sync/test_file.py @@ -0,0 +1,281 @@ +import datetime +from io import BytesIO, StringIO + +import pytest + +from e2b import Volume +from e2b.exceptions import NotFoundException, VolumeException + + +class TestWriteFileAndReadFile: + def test_write_and_read_text_file(self, volume: Volume): + path = "/test.txt" + content = "Hello, World!" + + volume.write_file(path, content) + read_content = volume.read_file(path, format="text") + + assert read_content == content + + def test_write_and_read_bytes(self, volume: Volume): + path = "/test-bytes.txt" + content = "Test bytes content" + content_bytes = content.encode("utf-8") + + volume.write_file(path, content_bytes) + read_bytes = volume.read_file(path, format="bytes") + + assert read_bytes == content_bytes + + def test_write_and_read_binary_data(self, volume: Volume): + path = "/binary.bin" + binary_data = bytes([0, 1, 2, 3, 4]) + + volume.write_file(path, binary_data) + read_bytes = volume.read_file(path, format="bytes") + + assert read_bytes == binary_data + + def test_write_and_read_stream(self, volume: Volume): + path = "/test-stream.txt" + content = "Test stream content" + stream = BytesIO(content.encode("utf-8")) + + volume.write_file(path, stream) + read_stream = volume.read_file(path, format="stream") + read_content = b"".join(read_stream).decode("utf-8") + + assert read_content == content + + def test_write_and_read_text_stream(self, volume: Volume): + path = "/test-text-stream.txt" + content = "Test text stream content" + stream = StringIO(content) + + volume.write_file(path, stream) + read_content = volume.read_file(path, format="text") + + assert read_content == content + + def test_write_and_read_empty_file(self, volume: Volume): + path = "/empty.txt" + content = "" + + volume.write_file(path, content) + read_content = volume.read_file(path, format="text") + + assert read_content == content + + def test_overwrite_with_force(self, volume: Volume): + path = "/overwrite.txt" + initial_content = "Initial content" + new_content = "New content" + + volume.write_file(path, initial_content) + volume.write_file(path, new_content, force=True) + read_content = volume.read_file(path, format="text") + + assert read_content == new_content + + def test_write_existing_file_without_force_raises(self, volume: Volume): + path = "/no-overwrite.txt" + initial_content = "Initial content" + new_content = "New content" + + volume.write_file(path, initial_content) + with pytest.raises(VolumeException): + volume.write_file(path, new_content, force=False) + + def test_write_file_with_metadata(self, volume: Volume): + path = "/metadata.txt" + content = "File with metadata" + + volume.write_file(path, content, uid=1000, gid=1000, mode=0o644) + + entry_info = volume.get_info(path) + assert entry_info.type.value == "file" + assert entry_info.uid == 1000 + assert entry_info.gid == 1000 + assert entry_info.mode == 0o644 + + def test_write_file_in_nested_directory(self, volume: Volume): + dir_path = "/nested/deep/path" + file_path = f"{dir_path}/file.txt" + content = "Nested file content" + + volume.make_dir(dir_path, force=True) + volume.write_file(file_path, content) + read_content = volume.read_file(file_path, format="text") + + assert read_content == content + + +class TestGetInfo: + def test_get_info_for_file(self, volume: Volume): + path = "/info-file.txt" + content = "File for info test" + + volume.write_file(path, content) + info = volume.get_info(path) + + assert info.name == "info-file.txt" + assert info.type.value == "file" + assert info.path == path + assert isinstance(info.mtime, datetime.datetime) + assert isinstance(info.ctime, datetime.datetime) + + def test_get_info_for_directory(self, volume: Volume): + path = "/info-dir" + + volume.make_dir(path) + info = volume.get_info(path) + + assert info.name == "info-dir" + assert info.type.value == "directory" + assert info.path == path + + def test_exists_returns_false_for_nonexistent(self, volume: Volume): + assert volume.exists("/non-existent.txt") is False + + +class TestUpdateMetadata: + def test_update_file_metadata(self, volume: Volume): + path = "/metadata-update.txt" + volume.write_file(path, "Content") + + updated = volume.update_metadata(path, uid=1001, gid=1001, mode=0o755) + + assert updated.path == path + assert updated.type.value == "file" + assert updated.uid == 1001 + assert updated.gid == 1001 + assert updated.mode == 0o755 + + def test_update_metadata_nonexistent_raises(self, volume: Volume): + with pytest.raises(NotFoundException): + volume.update_metadata("/non-existent.txt", mode=0o644) + + +class TestMakeDir: + def test_create_directory(self, volume: Volume): + path = "/test-dir" + + volume.make_dir(path) + info = volume.get_info(path) + + assert info.type.value == "directory" + assert info.path == path + + def test_create_nested_directories_with_force(self, volume: Volume): + path = "/nested/deep/directory" + + volume.make_dir(path, force=True) + info = volume.get_info(path) + + assert info.type.value == "directory" + + def test_create_existing_directory_without_force_raises(self, volume: Volume): + path = "/existing-dir" + + volume.make_dir(path) + with pytest.raises(VolumeException): + volume.make_dir(path, force=False) + + def test_create_directory_with_metadata(self, volume: Volume): + path = "/dir-with-metadata" + + volume.make_dir(path, uid=1000, gid=1000, mode=0o755) + + info = volume.get_info(path) + assert info.type.value == "directory" + assert info.uid == 1000 + assert info.gid == 1000 + assert info.mode & 0o777 == 0o755 + + +class TestList: + def test_list_directory_contents(self, volume: Volume): + volume.write_file("/file1.txt", "Content 1") + volume.write_file("/file2.txt", "Content 2") + volume.make_dir("/dir1") + + entries = volume.list("/") + + assert len(entries) >= 3 + file_names = sorted([e.name for e in entries]) + assert "file1.txt" in file_names + assert "file2.txt" in file_names + assert "dir1" in file_names + + def test_list_nested_directory(self, volume: Volume): + volume.make_dir("/nested", force=True) + volume.write_file("/nested/file.txt", "Content") + + entries = volume.list("/nested") + + assert len(entries) >= 1 + assert any(e.name == "file.txt" for e in entries) + + @pytest.mark.skip(reason="depth option not yet supported") + def test_list_with_depth(self, volume: Volume): + volume.make_dir("/deep/nested/structure", force=True) + volume.write_file("/deep/nested/structure/file.txt", "Content") + + entries = volume.list("/deep", depth=2) + + assert len(entries) > 0 + + def test_list_nonexistent_raises(self, volume: Volume): + with pytest.raises(NotFoundException): + volume.list("/non-existent") + + +class TestRemove: + def test_remove_file(self, volume: Volume): + path = "/to-remove.txt" + volume.write_file(path, "Content") + + volume.remove(path) + + assert volume.exists(path) is False + + def test_remove_directory(self, volume: Volume): + path = "/to-remove-dir" + volume.make_dir(path) + + volume.remove(path) + + assert volume.exists(path) is False + + def test_remove_directory_recursively(self, volume: Volume): + dir_path = "/recursive-dir" + volume.make_dir(f"{dir_path}/nested", force=True) + volume.write_file(f"{dir_path}/nested/file.txt", "Content") + + volume.remove(dir_path) + + assert volume.exists(dir_path) is False + + def test_remove_nonexistent_raises(self, volume: Volume): + with pytest.raises(NotFoundException): + volume.remove("/non-existent.txt") + + +class TestFileOperationsLifecycle: + def test_directory_with_multiple_files(self, volume: Volume): + dir_path = "/multi-file-dir" + volume.make_dir(dir_path) + + files = ["file1.txt", "file2.txt", "file3.txt"] + for file_name in files: + volume.write_file(f"{dir_path}/{file_name}", f"Content of {file_name}") + + entries = volume.list(dir_path) + assert len(entries) >= len(files) + + for file_name in files: + content = volume.read_file(f"{dir_path}/{file_name}", format="text") + assert content == f"Content of {file_name}" + + volume.remove(dir_path) + assert volume.exists(dir_path) is False diff --git a/packages/python-sdk/tests/sync/volume_sync/test_volume.py b/packages/python-sdk/tests/sync/volume_sync/test_volume.py new file mode 100644 index 0000000..fb9a172 --- /dev/null +++ b/packages/python-sdk/tests/sync/volume_sync/test_volume.py @@ -0,0 +1,175 @@ +from http import HTTPStatus +from uuid import uuid4 + +import pytest + +from e2b import Volume +from e2b.exceptions import NotFoundException +from e2b.api.client.models.volume_and_token import VolumeAndToken +from e2b.api.client.types import Response +import e2b.api.client.api.volumes.post_volumes as post_volumes_mod +import e2b.api.client.api.volumes.get_volumes as get_volumes_mod +import e2b.api.client.api.volumes.get_volumes_volume_id as get_volume_mod +import e2b.api.client.api.volumes.delete_volumes_volume_id as delete_volume_mod + +# In-memory store for mock volumes +_volumes: dict[str, VolumeAndToken] = {} + + +@pytest.fixture(autouse=True) +def mock_volume_api(monkeypatch, test_api_key): + monkeypatch.setenv("E2B_API_KEY", test_api_key) + _volumes.clear() + + def mock_post_volumes(*, client, body): + vol_id = str(uuid4()) + token = f"vol-token-{uuid4()}" + vol = VolumeAndToken(volume_id=vol_id, name=body.name, token=token) + _volumes[vol_id] = vol + return Response( + status_code=HTTPStatus(201), + content=b"", + headers={}, + parsed=vol, + ) + + def mock_get_volumes(*, client): + return Response( + status_code=HTTPStatus(200), + content=b"", + headers={}, + parsed=list(_volumes.values()), + ) + + def mock_get_volume(volume_id, *, client): + vol = _volumes.get(volume_id) + if vol is None: + return Response( + status_code=HTTPStatus(404), + content=b"", + headers={}, + parsed=None, + ) + return Response( + status_code=HTTPStatus(200), + content=b"", + headers={}, + parsed=vol, + ) + + def mock_delete_volume(volume_id, *, client): + if volume_id not in _volumes: + return Response( + status_code=HTTPStatus(404), + content=b"", + headers={}, + parsed=None, + ) + del _volumes[volume_id] + return Response( + status_code=HTTPStatus(204), + content=b"", + headers={}, + parsed=None, + ) + + monkeypatch.setattr(post_volumes_mod, "sync_detailed", mock_post_volumes) + monkeypatch.setattr(get_volumes_mod, "sync_detailed", mock_get_volumes) + monkeypatch.setattr(get_volume_mod, "sync_detailed", mock_get_volume) + monkeypatch.setattr(delete_volume_mod, "sync_detailed", mock_delete_volume) + + +def test_create_volume(): + vol = Volume.create("test-volume") + + assert vol is not None + assert vol.volume_id is not None + assert vol.name == "test-volume" + assert vol.token is not None + + +def test_get_volume_info(): + created = Volume.create("info-volume") + info = Volume.get_info(created.volume_id) + + assert info.volume_id == created.volume_id + assert info.name == "info-volume" + assert info.token is not None + + +def test_list_volumes(): + Volume.create("vol-a") + Volume.create("vol-b") + + volumes = Volume.list() + + assert len(volumes) == 2 + names = sorted([v.name for v in volumes]) + assert names == ["vol-a", "vol-b"] + + +def test_list_volumes_empty(): + volumes = Volume.list() + assert len(volumes) == 0 + + +def test_destroy_volume(): + vol = Volume.create("to-delete") + result = Volume.destroy(vol.volume_id) + + assert result is True + + volumes = Volume.list() + assert len(volumes) == 0 + + +def test_destroy_nonexistent_volume(): + result = Volume.destroy("non-existent-id") + assert result is False + + +def test_get_info_nonexistent_volume(): + with pytest.raises(NotFoundException): + Volume.get_info("non-existent-id") + + +def test_create_volume_keeps_proxy_for_content_calls(): + vol = Volume.create("proxy-volume", proxy="http://user:pass@127.0.0.1:8080") + + # The proxy is stored on the instance... + assert vol._proxy == "http://user:pass@127.0.0.1:8080" + + # ...and instance methods (which build a VolumeConnectionConfig with no + # per-call proxy) pick it up rather than falling back to no proxy. + config = vol._get_volume_config() + assert config.proxy == "http://user:pass@127.0.0.1:8080" + + +def test_volume_per_call_proxy_overrides_instance(): + vol = Volume.create("proxy-volume", proxy="http://127.0.0.1:8080") + + config = vol._get_volume_config(proxy="http://127.0.0.1:9090") + assert config.proxy == "http://127.0.0.1:9090" + + +def test_volume_full_lifecycle(): + # Create + vol = Volume.create("lifecycle-vol") + assert vol.name == "lifecycle-vol" + + # Get info + info = Volume.get_info(vol.volume_id) + assert info.name == "lifecycle-vol" + + # List + volumes = Volume.list() + assert len(volumes) == 1 + assert volumes[0].volume_id == vol.volume_id + + # Destroy + destroyed = Volume.destroy(vol.volume_id) + assert destroyed is True + + # List again + volumes = Volume.list() + assert len(volumes) == 0 diff --git a/packages/python-sdk/tests/sync/volume_sync/test_volume_content.py b/packages/python-sdk/tests/sync/volume_sync/test_volume_content.py new file mode 100644 index 0000000..05259bc --- /dev/null +++ b/packages/python-sdk/tests/sync/volume_sync/test_volume_content.py @@ -0,0 +1,68 @@ +import httpx +import pytest + +from e2b import Volume +from e2b.exceptions import NotFoundException +import e2b.volume.volume_sync as volume_sync_mod + +_files = { + "hello.txt": b"hello world", + "empty.txt": b"", +} + + +def _handler(request: httpx.Request) -> httpx.Response: + path = request.url.params.get("path") + content = _files.get(path) + if content is None: + return httpx.Response(404) + return httpx.Response(200, content=content) + + +@pytest.fixture +def volume(monkeypatch) -> Volume: + real_get_api_client = volume_sync_mod.get_volume_api_client + + def mock_get_api_client(config, **kwargs): + client = real_get_api_client(config, **kwargs) + client.set_httpx_client( + httpx.Client( + base_url=config.api_url, + transport=httpx.MockTransport(_handler), + ) + ) + return client + + monkeypatch.setattr(volume_sync_mod, "get_volume_api_client", mock_get_api_client) + return Volume(volume_id="vol-1", name="test-volume", token="vol-token") + + +def test_read_file_stream_yields_content(volume: Volume): + stream = volume.read_file("hello.txt", format="stream") + assert b"".join(stream) == b"hello world" + + +def test_read_file_stream_raises_for_missing_path(volume: Volume): + with pytest.raises(NotFoundException): + for _ in volume.read_file("missing.txt", format="stream"): + pass + + +def test_read_file_stream_of_empty_file(volume: Volume): + stream = volume.read_file("empty.txt", format="stream") + assert b"".join(stream) == b"" + + +def test_read_file_text_and_bytes(volume: Volume): + assert volume.read_file("hello.txt") == "hello world" + assert volume.read_file("hello.txt", format="bytes") == b"hello world" + + +def test_read_file_text_and_bytes_of_empty_file(volume: Volume): + assert volume.read_file("empty.txt") == "" + assert volume.read_file("empty.txt", format="bytes") == b"" + + +def test_read_file_missing_path_raises(volume: Volume): + with pytest.raises(NotFoundException): + volume.read_file("missing.txt") diff --git a/packages/python-sdk/tests/test_api_client_transport.py b/packages/python-sdk/tests/test_api_client_transport.py new file mode 100644 index 0000000..e54848e --- /dev/null +++ b/packages/python-sdk/tests/test_api_client_transport.py @@ -0,0 +1,421 @@ +import asyncio +import gc +from concurrent.futures import ThreadPoolExecutor + +import httpx +import pytest + +from e2b.api.client_async import AsyncEnvdTransportWithLogger, AsyncTransportWithLogger +from e2b.api.client_async import get_api_client as get_async_api_client +from e2b.api.client_async import get_envd_transport as get_async_envd_transport +from e2b.api.client_async import get_transport as get_async_transport +from e2b.api.client_sync import EnvdTransportWithLogger, TransportWithLogger +from e2b.api.client_sync import get_api_client as get_sync_api_client +from e2b.api.client_sync import get_envd_transport as get_sync_envd_transport +from e2b.api.client_sync import get_transport as get_sync_transport +from e2b.connection_config import ConnectionConfig + + +def reset_sync_api_transports(): + TransportWithLogger._thread_local.instances = {} + + +def reset_sync_envd_transports(): + EnvdTransportWithLogger._thread_local.instances = {} + + +def run_in_worker_thread(fn): + with ThreadPoolExecutor(max_workers=1) as executor: + return executor.submit(fn).result() + + +def test_sync_api_client_proxy_uses_explicit_transport(test_api_key): + reset_sync_api_transports() + config = ConnectionConfig( + api_key=test_api_key, + proxy="http://127.0.0.1:9999", + ) + + api_client = get_sync_api_client(config) + httpx_client = api_client.get_httpx_client() + + try: + assert "proxy" not in api_client._httpx_args + assert httpx_client._transport is get_sync_transport(config) + assert httpx_client._mounts == {} + finally: + httpx_client.close() + reset_sync_api_transports() + + +def test_sync_get_transport_http2_opt_out_returns_distinct_instance(test_api_key): + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key) + + try: + http2_transport = get_sync_transport(config) + http1_transport = get_sync_transport(config, http2=False) + + assert http2_transport is not http1_transport + assert http2_transport._pool._http2 is True + assert http1_transport._pool._http2 is False + # Subsequent calls with the same http2 flag return the cached + # instance. + assert get_sync_transport(config) is http2_transport + assert get_sync_transport(config, http2=False) is http1_transport + finally: + reset_sync_api_transports() + + +def test_sync_get_transport_keyed_by_proxy(test_api_key): + reset_sync_api_transports() + proxied_config = ConnectionConfig( + api_key=test_api_key, + proxy="http://127.0.0.1:9999", + ) + direct_config = ConnectionConfig(api_key=test_api_key) + other_proxy_config = ConnectionConfig( + api_key=test_api_key, + proxy="http://127.0.0.1:8888", + ) + + try: + proxied_transport = get_sync_transport(proxied_config) + direct_transport = get_sync_transport(direct_config) + other_proxy_transport = get_sync_transport(other_proxy_config) + + assert proxied_transport is not direct_transport + assert proxied_transport is not other_proxy_transport + assert direct_transport is not other_proxy_transport + # The same proxy still reuses the cached instance. + assert get_sync_transport(proxied_config) is proxied_transport + assert get_sync_transport(direct_config) is direct_transport + finally: + reset_sync_api_transports() + + +def test_sync_api_client_applies_request_timeout(test_api_key): + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key, request_timeout=1.5) + + api_client = get_sync_api_client(config) + httpx_client = api_client.get_httpx_client() + + try: + assert httpx_client.timeout == httpx.Timeout(1.5) + finally: + httpx_client.close() + reset_sync_api_transports() + + +def test_sync_api_client_request_timeout_zero_disables_timeout(test_api_key): + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key, request_timeout=0) + + api_client = get_sync_api_client(config) + httpx_client = api_client.get_httpx_client() + + try: + assert httpx_client.timeout == httpx.Timeout(None) + finally: + httpx_client.close() + reset_sync_api_transports() + + +def test_sync_envd_transport_uses_separate_cache(test_api_key): + reset_sync_api_transports() + reset_sync_envd_transports() + config = ConnectionConfig(api_key=test_api_key) + + try: + api_transport = get_sync_transport(config) + envd_transport = get_sync_envd_transport(config) + + assert api_transport is not envd_transport + assert get_sync_transport(config) is api_transport + assert get_sync_envd_transport(config) is envd_transport + assert envd_transport._pool._http2 is True + finally: + reset_sync_api_transports() + reset_sync_envd_transports() + + +def test_sync_api_transport_cache_reuses_within_thread_and_isolates_across_threads( + test_api_key, +): + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key) + + try: + main_transport = get_sync_transport(config) + same_thread_transport = get_sync_transport(config) + worker_thread_transport = run_in_worker_thread( + lambda: get_sync_transport(config) + ) + + assert same_thread_transport is main_transport + assert worker_thread_transport is not main_transport + finally: + reset_sync_api_transports() + + +def test_sync_api_client_cache_reuses_within_thread_and_isolates_across_threads( + test_api_key, +): + reset_sync_api_transports() + config = ConnectionConfig(api_key=test_api_key) + api_client = get_sync_api_client(config) + + def get_worker_client_ids(): + httpx_client = api_client.get_httpx_client() + try: + return ( + id(httpx_client), + id(api_client.get_httpx_client()), + id(httpx_client._transport), + id(get_sync_transport(config)), + ) + finally: + httpx_client.close() + + try: + main_client = api_client.get_httpx_client() + ( + worker_client_id, + worker_cached_client_id, + worker_transport_id, + worker_cached_transport_id, + ) = run_in_worker_thread(get_worker_client_ids) + + assert api_client.get_httpx_client() is main_client + assert worker_client_id == worker_cached_client_id + assert worker_transport_id == worker_cached_transport_id + assert worker_client_id != id(main_client) + assert worker_transport_id != id(main_client._transport) + finally: + main_client.close() + reset_sync_api_transports() + + +def test_sync_envd_transport_cache_is_thread_local(test_api_key): + reset_sync_envd_transports() + config = ConnectionConfig(api_key=test_api_key) + + try: + main_transport = get_sync_envd_transport(config) + thread_transport = run_in_worker_thread(lambda: get_sync_envd_transport(config)) + + assert main_transport is get_sync_envd_transport(config) + assert thread_transport is not main_transport + assert main_transport._pool._http2 is True + assert thread_transport._pool._http2 is True + finally: + reset_sync_envd_transports() + + +@pytest.mark.asyncio +async def test_async_api_client_proxy_uses_explicit_transport(test_api_key): + AsyncTransportWithLogger._instances.clear() + config = ConnectionConfig( + api_key=test_api_key, + proxy="http://127.0.0.1:9999", + ) + + api_client = get_async_api_client(config) + httpx_client = api_client.get_async_httpx_client() + transport = AsyncTransportWithLogger._instances[asyncio.get_running_loop()][ + (True, "http://127.0.0.1:9999") + ] + + try: + assert "proxy" not in api_client._httpx_args + assert httpx_client._transport is transport + assert httpx_client._mounts == {} + finally: + await httpx_client.aclose() + AsyncTransportWithLogger._instances.clear() + + +@pytest.mark.asyncio +async def test_async_get_transport_http2_opt_out_returns_distinct_instance( + test_api_key, +): + AsyncTransportWithLogger._instances.clear() + config = ConnectionConfig(api_key=test_api_key) + + try: + http2_transport = get_async_transport(config) + http1_transport = get_async_transport(config, http2=False) + + assert http2_transport is not http1_transport + assert http2_transport._pool._http2 is True + assert http1_transport._pool._http2 is False + # Subsequent calls with the same http2 flag return the cached + # instance. + assert get_async_transport(config) is http2_transport + assert get_async_transport(config, http2=False) is http1_transport + finally: + AsyncTransportWithLogger._instances.clear() + + +@pytest.mark.asyncio +async def test_async_get_transport_keyed_by_proxy(test_api_key): + AsyncTransportWithLogger._instances.clear() + proxied_config = ConnectionConfig( + api_key=test_api_key, + proxy="http://127.0.0.1:9999", + ) + direct_config = ConnectionConfig(api_key=test_api_key) + + try: + proxied_transport = get_async_transport(proxied_config) + direct_transport = get_async_transport(direct_config) + + assert proxied_transport is not direct_transport + # The same proxy still reuses the cached instance. + assert get_async_transport(proxied_config) is proxied_transport + assert get_async_transport(direct_config) is direct_transport + finally: + AsyncTransportWithLogger._instances.clear() + + +@pytest.mark.asyncio +async def test_async_api_client_applies_request_timeout(test_api_key): + AsyncTransportWithLogger._instances.clear() + config = ConnectionConfig(api_key=test_api_key, request_timeout=1.5) + + api_client = get_async_api_client(config) + httpx_client = api_client.get_async_httpx_client() + + try: + assert httpx_client.timeout == httpx.Timeout(1.5) + finally: + await httpx_client.aclose() + AsyncTransportWithLogger._instances.clear() + + +@pytest.mark.asyncio +async def test_async_api_client_cache_reuses_within_loop_and_isolates_across_loops( + test_api_key, +): + AsyncTransportWithLogger._instances.clear() + config = ConnectionConfig(api_key=test_api_key) + api_client = get_async_api_client(config) + + async def get_client_ids(): + httpx_client = api_client.get_async_httpx_client() + try: + return ( + id(httpx_client), + id(api_client.get_async_httpx_client()), + id(httpx_client._transport), + id(get_async_transport(config)), + ) + finally: + await httpx_client.aclose() + + try: + main_client = api_client.get_async_httpx_client() + ( + worker_client_id, + worker_cached_client_id, + worker_transport_id, + worker_cached_transport_id, + ) = await asyncio.get_running_loop().run_in_executor( + None, + lambda: asyncio.run(get_client_ids()), + ) + + assert api_client.get_async_httpx_client() is main_client + assert worker_client_id == worker_cached_client_id + assert worker_transport_id == worker_cached_transport_id + assert worker_client_id != id(main_client) + assert worker_transport_id != id(main_client._transport) + finally: + await main_client.aclose() + AsyncTransportWithLogger._instances.clear() + + +def test_async_transport_not_reused_across_sequential_loops(test_api_key): + AsyncTransportWithLogger._instances.clear() + config = ConnectionConfig(api_key=test_api_key) + + async def get_transport(): + return get_async_transport(config) + + try: + loop_a = asyncio.new_event_loop() + try: + transport_a = loop_a.run_until_complete(get_transport()) + finally: + loop_a.close() + del loop_a + gc.collect() + + # The cache entry dies with the loop, so a later loop can never + # inherit a transport bound to a closed loop, even when CPython + # reuses the dead loop's object id. + assert len(AsyncTransportWithLogger._instances) == 0 + + loop_b = asyncio.new_event_loop() + try: + transport_b = loop_b.run_until_complete(get_transport()) + finally: + loop_b.close() + + assert transport_b is not transport_a + finally: + AsyncTransportWithLogger._instances.clear() + + +def test_async_api_client_not_reused_across_sequential_loops(test_api_key): + AsyncTransportWithLogger._instances.clear() + config = ConnectionConfig(api_key=test_api_key) + api_client = get_async_api_client(config) + + async def get_client(): + client = api_client.get_async_httpx_client() + assert api_client.get_async_httpx_client() is client + return client + + try: + loop_a = asyncio.new_event_loop() + try: + client_a = loop_a.run_until_complete(get_client()) + loop_a.run_until_complete(client_a.aclose()) + finally: + loop_a.close() + del loop_a + gc.collect() + + assert len(api_client._async_clients) == 0 + + loop_b = asyncio.new_event_loop() + try: + client_b = loop_b.run_until_complete(get_client()) + loop_b.run_until_complete(client_b.aclose()) + finally: + loop_b.close() + + assert client_b is not client_a + finally: + AsyncTransportWithLogger._instances.clear() + + +@pytest.mark.asyncio +async def test_async_envd_transport_uses_separate_cache(test_api_key): + AsyncTransportWithLogger._instances.clear() + AsyncEnvdTransportWithLogger._instances.clear() + config = ConnectionConfig(api_key=test_api_key) + + try: + api_transport = get_async_transport(config) + envd_transport = get_async_envd_transport(config) + + assert api_transport is not envd_transport + assert get_async_transport(config) is api_transport + assert get_async_envd_transport(config) is envd_transport + assert envd_transport._pool._http2 is True + finally: + AsyncTransportWithLogger._instances.clear() + AsyncEnvdTransportWithLogger._instances.clear() diff --git a/packages/python-sdk/tests/test_command_handle.py b/packages/python-sdk/tests/test_command_handle.py new file mode 100644 index 0000000..c8cf957 --- /dev/null +++ b/packages/python-sdk/tests/test_command_handle.py @@ -0,0 +1,281 @@ +import asyncio +from typing import Any, cast + +import pytest + +from e2b.envd.process import process_pb2 +from e2b.sandbox_async.commands.command_handle import AsyncCommandHandle +from e2b.sandbox_sync.commands.command_handle import CommandHandle + +EMOJI = "😀" +EMOJI_BYTES = EMOJI.encode("utf-8") # 4 bytes + + +def _stdout_event(data: bytes) -> process_pb2.StartResponse: + return process_pb2.StartResponse( + event=process_pb2.ProcessEvent( + data=process_pb2.ProcessEvent.DataEvent(stdout=data) + ) + ) + + +def _stderr_event(data: bytes) -> process_pb2.StartResponse: + return process_pb2.StartResponse( + event=process_pb2.ProcessEvent( + data=process_pb2.ProcessEvent.DataEvent(stderr=data) + ) + ) + + +def _end_event(exit_code: int = 0) -> process_pb2.StartResponse: + return process_pb2.StartResponse( + event=process_pb2.ProcessEvent( + end=process_pb2.ProcessEvent.EndEvent( + exit_code=exit_code, exited=True, status="exited" + ) + ) + ) + + +async def _kill() -> bool: + return True + + +class _AsyncControllableEvents: + """Async event source that delivers items on demand. + + Lets a test hold the handle's event-handling task blocked waiting for the + next event (idle between bursts), then push a late event after + ``disconnect()`` to confirm it never reaches the callback — the transport + condition that triggers the JS SDK leak. + """ + + def __init__(self): + self._queue: asyncio.Queue = asyncio.Queue() + self._closed = False + + def __aiter__(self): + return self + + async def __anext__(self): + if self._closed and self._queue.empty(): + raise StopAsyncIteration + item = await self._queue.get() + if item is None: + raise StopAsyncIteration + return item + + def push(self, event): + self._queue.put_nowait(event) + + async def aclose(self): + # Intentionally does NOT unblock a read that is already suspended on + # the queue: this models the JS-leak transport condition where the + # stream stays open past the close request. The only thing that stops + # the handle's event loop here is task cancellation in disconnect(). + self._closed = True + + +async def test_async_disconnect_stops_callbacks(): + events = _AsyncControllableEvents() + chunks = [] + handle = AsyncCommandHandle( + pid=1, + handle_kill=_kill, + # The handle only async-iterates and aclose()s the stream; this stand-in + # satisfies both without being a real async generator. + events=cast(Any, events), + on_stdout=chunks.append, + ) + + # First burst is delivered to the live subscriber. + events.push(_stdout_event(b"a")) + for _ in range(200): + if chunks == ["a"]: + break + await asyncio.sleep(0.005) + assert chunks == ["a"] + + # disconnect() cancels the event-handling task and closes the stream, so + # once it returns the callback must not fire again. + await handle.disconnect() + + # A late event (stdout arriving after disconnect) must never reach the + # callback. + events.push(_stdout_event(b"b")) + await asyncio.sleep(0.05) + assert chunks == ["a"] + + +def test_sync_has_no_background_subscription(): + # The sync handle has no detached event-handling task: events are consumed + # only while the caller iterates (e.g. inside wait()), so there is no + # subscription that could keep firing after disconnect(). + consumed = [] + + def events(): + consumed.append("started") + yield _stdout_event(b"a") + yield _end_event() + + handle = CommandHandle(pid=1, handle_kill=lambda: True, events=events()) + + # Nothing is consumed until the caller iterates. + assert consumed == [] + + # disconnect() just closes the (un-started) stream — still nothing consumed. + handle.disconnect() + assert consumed == [] + + +def test_sync_records_result_before_yielding_flushed_chunk(): + # A consumer that stops iterating right after the end event's flushed chunk + # must still observe the exit code: the result is recorded before the + # flushed chunk is yielded. + def events(): + yield _stdout_event(b"a" + EMOJI_BYTES[:2]) + yield _end_event(0) + + handle = CommandHandle(pid=1, handle_kill=lambda: True, events=events()) + iterator = iter(handle) + assert next(iterator) == ("a", None, None) + # The end event flushes a trailing replacement character; pull just that + # chunk and then stop iterating. + next(iterator) + iterator.close() + + assert handle._result is not None + assert handle._result.exit_code == 0 + assert handle._result.stdout == "a�" + + +def test_sync_decodes_multibyte_chars_split_across_chunks(): + def events(): + yield _stdout_event(b"a" + EMOJI_BYTES[:2]) + yield _stdout_event(EMOJI_BYTES[2:] + b"b") + yield _stderr_event(EMOJI_BYTES[:3]) + yield _stderr_event(EMOJI_BYTES[3:]) + yield _end_event() + + chunks = [] + handle = CommandHandle(pid=1, handle_kill=lambda: True, events=events()) + result = handle.wait(on_stdout=chunks.append) + + assert result.stdout == f"a{EMOJI}b" + assert result.stderr == EMOJI + assert "�" not in result.stdout + assert "�" not in result.stderr + assert "".join(chunks) == f"a{EMOJI}b" + + +def test_sync_replaces_incomplete_trailing_utf8(): + def events(): + yield _stdout_event(b"a" + EMOJI_BYTES[:2]) + yield _end_event() + + handle = CommandHandle(pid=1, handle_kill=lambda: True, events=events()) + result = handle.wait() + + assert result.stdout == "a�" + + +async def test_async_decodes_multibyte_chars_split_across_chunks(): + async def events(): + yield _stdout_event(b"a" + EMOJI_BYTES[:2]) + yield _stdout_event(EMOJI_BYTES[2:] + b"b") + yield _stderr_event(EMOJI_BYTES[:3]) + yield _stderr_event(EMOJI_BYTES[3:]) + yield _end_event() + + chunks = [] + handle = AsyncCommandHandle( + pid=1, + handle_kill=_kill, + events=events(), + on_stdout=chunks.append, + ) + result = await handle.wait() + + assert result.stdout == f"a{EMOJI}b" + assert result.stderr == EMOJI + assert "�" not in result.stdout + assert "�" not in result.stderr + assert "".join(chunks) == f"a{EMOJI}b" + + +async def test_async_replaces_incomplete_trailing_utf8(): + async def events(): + yield _stdout_event(b"a" + EMOJI_BYTES[:2]) + yield _end_event() + + handle = AsyncCommandHandle(pid=1, handle_kill=_kill, events=events()) + result = await handle.wait() + + assert result.stdout == "a�" + + +def test_sync_flushes_incomplete_trailing_utf8_without_end_event(): + def events(): + yield _stdout_event(b"a" + EMOJI_BYTES[:2]) + + chunks = [] + handle = CommandHandle(pid=1, handle_kill=lambda: True, events=events()) + for stdout, _, _ in handle: + if stdout is not None: + chunks.append(stdout) + + assert "".join(chunks) == "a�" + + +async def test_async_flushes_incomplete_trailing_utf8_without_end_event(): + async def events(): + yield _stdout_event(b"a" + EMOJI_BYTES[:2]) + + chunks = [] + handle = AsyncCommandHandle( + pid=1, + handle_kill=_kill, + events=events(), + on_stdout=chunks.append, + ) + await handle._wait + + assert "".join(chunks) == "a�" + + +def test_sync_flushes_incomplete_trailing_utf8_on_stream_error(): + def events(): + yield _stdout_event(b"a" + EMOJI_BYTES[:2]) + raise RuntimeError("stream died") + + chunks = [] + handle = CommandHandle(pid=1, handle_kill=lambda: True, events=events()) + + # The stream raises before an end event, but the buffered bytes must still + # be flushed as a replacement character before the error is surfaced. + with pytest.raises(RuntimeError): + for stdout, _, _ in handle: + if stdout is not None: + chunks.append(stdout) + + assert "".join(chunks) == "a�" + + +async def test_async_flushes_incomplete_trailing_utf8_on_stream_error(): + async def events(): + yield _stdout_event(b"a" + EMOJI_BYTES[:2]) + raise RuntimeError("stream died") + + chunks = [] + handle = AsyncCommandHandle( + pid=1, + handle_kill=_kill, + events=events(), + on_stdout=chunks.append, + ) + await handle._wait + + # The stream raised before an end event, but the buffered bytes must still + # be flushed to the stdout callback as a replacement character. + assert "".join(chunks) == "a�" + assert isinstance(handle._iteration_exception, RuntimeError) diff --git a/packages/python-sdk/tests/test_connection_config.py b/packages/python-sdk/tests/test_connection_config.py new file mode 100644 index 0000000..9c7c680 --- /dev/null +++ b/packages/python-sdk/tests/test_connection_config.py @@ -0,0 +1,236 @@ +from e2b import ConnectionConfig + + +def test_api_url_defaults_correctly(monkeypatch): + monkeypatch.setenv("E2B_DOMAIN", "") + monkeypatch.delenv("E2B_API_URL", raising=False) + + config = ConnectionConfig() + assert config.api_url == "https://api.e2b.app" + + +def test_api_url_in_args(): + config = ConnectionConfig(api_url="http://localhost:8080") + assert config.api_url == "http://localhost:8080" + + +def test_api_url_in_env_var(monkeypatch): + monkeypatch.setenv("E2B_API_URL", "http://localhost:8080") + + config = ConnectionConfig() + assert config.api_url == "http://localhost:8080" + + +def test_api_url_has_correct_priority(monkeypatch): + monkeypatch.setenv("E2B_API_URL", "http://localhost:1111") + + config = ConnectionConfig(api_url="http://localhost:8080") + assert config.api_url == "http://localhost:8080" + + +def test_validate_api_key_defaults_to_true(monkeypatch): + monkeypatch.delenv("E2B_VALIDATE_API_KEY", raising=False) + + config = ConnectionConfig() + assert config.validate_api_key is True + + +def test_validate_api_key_disabled_via_env_var(monkeypatch): + monkeypatch.setenv("E2B_VALIDATE_API_KEY", "false") + + config = ConnectionConfig() + assert config.validate_api_key is False + + +def test_validate_api_key_arg_has_priority_over_env_var(monkeypatch): + monkeypatch.setenv("E2B_VALIDATE_API_KEY", "true") + + config = ConnectionConfig(validate_api_key=False) + assert config.validate_api_key is False + + +def test_sandbox_url_uses_stable_host_for_supported_domain(): + config = ConnectionConfig(domain="e2b.app") + + assert config.get_sandbox_url("sandbox-id", "e2b.app") == "https://sandbox.e2b.app" + + +def test_sandbox_url_uses_stable_host_for_supported_non_prod_domain(): + config = ConnectionConfig(domain="e2b.dev") + + assert config.get_sandbox_url("sandbox-id", "e2b.dev") == "https://sandbox.e2b.dev" + + +def test_sandbox_url_uses_explicit_url_first(): + config = ConnectionConfig(sandbox_url="https://sandbox.example.com") + + assert ( + config.get_sandbox_url("sandbox-id", "e2b.app") == "https://sandbox.example.com" + ) + + +def test_sandbox_url_falls_back_to_per_sandbox_host_for_custom_domain(): + config = ConnectionConfig(domain="custom.example") + + assert ( + config.get_sandbox_url("sandbox-id", "custom.example") + == "https://49983-sandbox-id.custom.example" + ) + + +def test_sandbox_url_falls_back_to_per_sandbox_host_for_unsupported_subdomain(): + config = ConnectionConfig(domain="e2b.dev") + + assert ( + config.get_sandbox_url("sandbox-id", "sandbox.e2b.dev") + == "https://49983-sandbox-id.sandbox.e2b.dev" + ) + + +def test_sandbox_url_debug_uses_localhost(): + config = ConnectionConfig(debug=True) + + assert config.get_sandbox_url("sandbox-id", "e2b.app") == "http://localhost:49983" + + +def test_get_host_keeps_per_sandbox_host_for_supported_domain(): + config = ConnectionConfig(domain="e2b.app") + + assert config.get_host("sandbox-id", "e2b.app", 8888) == "8888-sandbox-id.e2b.app" + + +def test_sandbox_direct_url_keeps_per_sandbox_host_for_supported_domain(): + config = ConnectionConfig(domain="e2b.app") + + assert ( + config.get_sandbox_direct_url("sandbox-id", "e2b.app") + == "https://49983-sandbox-id.e2b.app" + ) + + +def test_sandbox_direct_url_uses_explicit_url_first(): + config = ConnectionConfig(sandbox_url="https://sandbox.example.com") + + assert ( + config.get_sandbox_direct_url("sandbox-id", "e2b.app") + == "https://sandbox.example.com" + ) + + +def test_debug_false_overrides_env_var(monkeypatch): + monkeypatch.setenv("E2B_DEBUG", "true") + + config = ConnectionConfig(debug=False) + assert config.debug is False + + +def test_debug_defaults_to_env_var(monkeypatch): + monkeypatch.setenv("E2B_DEBUG", "true") + + config = ConnectionConfig() + assert config.debug is True + + +def test_set_integration_appends_to_user_agent(): + ConnectionConfig.set_integration("testing/version") + try: + config = ConnectionConfig() + + assert config.headers["User-Agent"].startswith("e2b-python-sdk/") + assert config.headers["User-Agent"].endswith(" testing/version") + finally: + ConnectionConfig.set_integration(None) + + config = ConnectionConfig() + assert "testing" not in config.headers["User-Agent"] + + +def test_custom_user_agent_is_preserved_without_integration(): + config = ConnectionConfig(api_headers={"User-Agent": "custom/1.0"}) + + assert config.headers["User-Agent"] == "custom/1.0" + + +def test_custom_user_agent_wins_over_integration(monkeypatch): + monkeypatch.setattr(ConnectionConfig, "_integration", "testing/version") + + config = ConnectionConfig(api_headers={"User-Agent": "custom/1.0"}) + + assert config.headers["User-Agent"] == "custom/1.0" + + +def test_integration_survives_api_param_rebuilds(monkeypatch): + monkeypatch.setattr(ConnectionConfig, "_integration", "testing/version") + + config = ConnectionConfig() + rebuilt_config = ConnectionConfig(**config.get_api_params()) + + assert rebuilt_config.headers["User-Agent"].endswith(" testing/version") + assert rebuilt_config.get_api_params(api_headers={"X-Test": "1"})["headers"][ + "User-Agent" + ].endswith(" testing/version") + + +def test_cleared_integration_does_not_leak_into_api_param_rebuilds(): + ConnectionConfig.set_integration("testing/version") + try: + config = ConnectionConfig() + finally: + ConnectionConfig.set_integration(None) + + params = config.get_api_params() + assert not params["headers"]["User-Agent"].endswith(" testing/version") + + rebuilt_config = ConnectionConfig(**params) + assert not rebuilt_config.headers["User-Agent"].endswith(" testing/version") + + +def test_custom_user_agent_survives_api_param_rebuilds(monkeypatch): + monkeypatch.setattr(ConnectionConfig, "_integration", "testing/version") + + config = ConnectionConfig(api_headers={"User-Agent": "custom/1.0"}) + rebuilt_config = ConnectionConfig(**config.get_api_params()) + + assert rebuilt_config.headers["User-Agent"] == "custom/1.0" + + monkeypatch.setattr(ConnectionConfig, "_integration", None) + + config = ConnectionConfig(api_headers={"User-Agent": "custom/1.0"}) + rebuilt_config = ConnectionConfig(**config.get_api_params()) + assert rebuilt_config.headers["User-Agent"] == "custom/1.0" + + +def test_per_call_user_agent_override_wins_in_api_params(monkeypatch): + monkeypatch.setattr(ConnectionConfig, "_integration", "testing/version") + + config = ConnectionConfig() + params = config.get_api_params(api_headers={"User-Agent": "custom/1.0"}) + assert params["headers"]["User-Agent"] == "custom/1.0" + + +def test_per_call_api_headers_user_agent_wins_over_headers(): + config = ConnectionConfig() + params = config.get_api_params( + headers={"User-Agent": "from-headers/1.0"}, + api_headers={"User-Agent": "from-api-headers/1.0"}, + ) + assert params["headers"]["User-Agent"] == "from-api-headers/1.0" + + +def test_request_timeout_zero_means_no_timeout(): + config = ConnectionConfig(request_timeout=0) + assert config.request_timeout is None + assert config.get_request_timeout() is None + # A per-call value of 0 also disables the timeout. + assert config.get_request_timeout(0) is None + + +def test_get_api_params_includes_sandbox_url(): + config = ConnectionConfig(sandbox_url="https://sandbox.example.com") + + params = config.get_api_params() + assert params["sandbox_url"] == "https://sandbox.example.com" + + # Per-call override takes priority. + overridden = config.get_api_params(sandbox_url="https://sandbox.override.com") + assert overridden["sandbox_url"] == "https://sandbox.override.com" diff --git a/packages/python-sdk/tests/test_envd_api_exception.py b/packages/python-sdk/tests/test_envd_api_exception.py new file mode 100644 index 0000000..7c0694d --- /dev/null +++ b/packages/python-sdk/tests/test_envd_api_exception.py @@ -0,0 +1,84 @@ +import httpx + +from e2b.envd.api import ( + format_envd_api_exception, + handle_envd_api_transport_exception, +) +from e2b.exceptions import ( + AuthenticationException, + InvalidArgumentException, + NotEnoughSpaceException, + NotFoundException, + RateLimitException, + SandboxException, + TimeoutException, +) + + +def test_maps_400_to_invalid_argument(): + err = format_envd_api_exception(400, "Bad request") + assert isinstance(err, InvalidArgumentException) + + +def test_maps_401_to_authentication(): + err = format_envd_api_exception(401, "Invalid token") + assert isinstance(err, AuthenticationException) + + +def test_maps_404_to_not_found(): + err = format_envd_api_exception(404, "Not found") + assert isinstance(err, NotFoundException) + + +def test_maps_429_to_rate_limit(): + err = format_envd_api_exception(429, "Too many requests") + assert isinstance(err, RateLimitException) + assert "rate limited" in str(err) + + +def test_maps_502_to_timeout(): + err = format_envd_api_exception(502, "Bad gateway") + assert isinstance(err, TimeoutException) + + +def test_maps_507_to_not_enough_space(): + err = format_envd_api_exception(507, "No space left") + assert isinstance(err, NotEnoughSpaceException) + + +def test_falls_back_to_sandbox_exception(): + err = format_envd_api_exception(500, "Internal error") + assert isinstance(err, SandboxException) + assert "500" in str(err) + + +def test_returns_raw_remote_protocol_error_without_health_result(): + original = httpx.RemoteProtocolError("peer closed connection") + err = handle_envd_api_transport_exception(original) + assert err is original + + +def test_returns_raw_network_errors(): + original = httpx.ReadError("read failed") + err = handle_envd_api_transport_exception(original) + assert err is original + + +def test_returns_original_when_not_transport_error(): + original = ValueError("not transport") + err = handle_envd_api_transport_exception(original) + assert err is original + + +def test_health_result_confirms_sandbox_killed(): + err = handle_envd_api_transport_exception( + httpx.RemoteProtocolError("peer closed connection"), sandbox_running=False + ) + assert isinstance(err, TimeoutException) + assert "sandbox was killed or reached its end of life" in str(err) + + +def test_health_result_running_returns_raw_error(): + original = httpx.RemoteProtocolError("peer closed connection") + err = handle_envd_api_transport_exception(original, sandbox_running=True) + assert err is original diff --git a/packages/python-sdk/tests/test_envd_rpc_exception.py b/packages/python-sdk/tests/test_envd_rpc_exception.py new file mode 100644 index 0000000..c366f61 --- /dev/null +++ b/packages/python-sdk/tests/test_envd_rpc_exception.py @@ -0,0 +1,140 @@ +import httpcore + +from e2b_connect.client import Code, ConnectException +from e2b.envd.rpc import ( + ahandle_rpc_exception_with_health, + handle_rpc_exception, + handle_rpc_exception_with_health, +) +from e2b.exceptions import ( + AuthenticationException, + InvalidArgumentException, + NotFoundException, + RateLimitException, + SandboxException, + TimeoutException, +) + + +def test_maps_invalid_argument(): + err = handle_rpc_exception(ConnectException(Code.invalid_argument, "bad")) + assert isinstance(err, InvalidArgumentException) + + +def test_maps_unauthenticated(): + err = handle_rpc_exception(ConnectException(Code.unauthenticated, "nope")) + assert isinstance(err, AuthenticationException) + + +def test_maps_not_found(): + err = handle_rpc_exception(ConnectException(Code.not_found, "missing")) + assert isinstance(err, NotFoundException) + + +def test_maps_resource_exhausted_to_rate_limit(): + err = handle_rpc_exception(ConnectException(Code.resource_exhausted, "too many")) + assert isinstance(err, RateLimitException) + assert "Rate limit" in str(err) + + +def test_maps_unavailable_to_timeout(): + err = handle_rpc_exception(ConnectException(Code.unavailable, "gone")) + assert isinstance(err, TimeoutException) + + +def test_falls_back_to_sandbox_exception(): + err = handle_rpc_exception(ConnectException(Code.internal, "boom")) + assert isinstance(err, SandboxException) + + +def test_returns_raw_remote_protocol_error_without_health_result(): + original = httpcore.RemoteProtocolError( + "" + ) + err = handle_rpc_exception(original) + assert err is original + + +def test_returns_raw_network_errors(): + original = httpcore.ReadError("read failed") + err = handle_rpc_exception(original) + assert err is original + + +def test_maps_read_timeout_to_timeout(): + # A transport-level read timeout (e.g. a unary call exceeding `request_timeout`) + # must surface as a TimeoutException rather than leaking the raw httpcore error. + err = handle_rpc_exception(httpcore.ReadTimeout("the read operation timed out")) + assert isinstance(err, TimeoutException) + + +def test_maps_connect_timeout_to_timeout(): + err = handle_rpc_exception(httpcore.ConnectTimeout("connect timed out")) + assert isinstance(err, TimeoutException) + + +def test_returns_original_when_not_connect_exception(): + original = ValueError("not connect") + err = handle_rpc_exception(original) + assert err is original + + +def _stream_reset() -> httpcore.RemoteProtocolError: + return httpcore.RemoteProtocolError( + "" + ) + + +def test_health_check_confirms_sandbox_killed(): + err = handle_rpc_exception_with_health(_stream_reset(), lambda: False) + assert isinstance(err, TimeoutException) + assert "sandbox was killed or reached its end of life" in str(err) + + +def test_health_check_running_returns_raw_error(): + original = _stream_reset() + err = handle_rpc_exception_with_health(original, lambda: True) + assert err is original + + +def test_health_check_unknown_returns_raw_error(): + original = _stream_reset() + err = handle_rpc_exception_with_health(original, lambda: None) + assert err is original + + +def test_health_check_failure_returns_raw_error(): + def check(): + raise RuntimeError("health check failed") + + original = _stream_reset() + err = handle_rpc_exception_with_health(original, check) + assert err is original + + +def test_health_check_not_run_for_other_exceptions(): + def fail(): + raise AssertionError("health check should not run") + + err = handle_rpc_exception_with_health( + ConnectException(Code.not_found, "missing"), fail + ) + assert isinstance(err, NotFoundException) + + +async def test_async_health_check_confirms_sandbox_killed(): + async def check(): + return False + + err = await ahandle_rpc_exception_with_health(_stream_reset(), check) + assert isinstance(err, TimeoutException) + assert "sandbox was killed or reached its end of life" in str(err) + + +async def test_async_health_check_failure_returns_raw_error(): + async def check(): + raise RuntimeError("health check failed") + + original = _stream_reset() + err = await ahandle_rpc_exception_with_health(original, check) + assert err is original diff --git a/packages/python-sdk/tests/test_file_stream_reader.py b/packages/python-sdk/tests/test_file_stream_reader.py new file mode 100644 index 0000000..d499aec --- /dev/null +++ b/packages/python-sdk/tests/test_file_stream_reader.py @@ -0,0 +1,226 @@ +"""Unit tests for the streamed-read helpers. + +These exercise connection lifecycle (consume / context manager / explicit +close / idle timeout / abandonment) without hitting a real sandbox, using a +local chunked HTTP server. +""" + +import asyncio +import socket +import threading +import time +from typing import Optional + +import httpx +import pytest + +from e2b.sandbox.filesystem.filesystem import ( + AsyncFileStreamReader, + FileStreamReader, +) + +CHUNKS = [f"chunk{i}".encode() for i in range(5)] +EXPECTED = b"".join(CHUNKS) + + +def _start_chunked_server( + stall_before: Optional[int] = None, + stall_seconds: float = 0.0, +) -> int: + """Start a one-shot HTTP server that replies with a chunked body. + + When ``stall_before`` is set, the server sleeps ``stall_seconds`` before + sending that chunk index, so a reader with a shorter idle timeout times out. + Returns the server's port. + """ + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + sock.listen(1) + port = sock.getsockname()[1] + + def serve(): + try: + conn, _ = sock.accept() + while b"\r\n\r\n" not in conn.recv(65536): + pass + conn.sendall( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: application/octet-stream\r\n" + b"Transfer-Encoding: chunked\r\n\r\n" + ) + for idx, chunk in enumerate(CHUNKS): + if stall_before is not None and idx == stall_before: + time.sleep(stall_seconds) + conn.sendall(f"{len(chunk):x}\r\n".encode() + chunk + b"\r\n") + conn.sendall(b"0\r\n\r\n") + conn.close() + except OSError: + pass + finally: + sock.close() + + threading.Thread(target=serve, daemon=True).start() + return port + + +def _active_connections(client) -> int: + # Count connections that are still checked out (a leaked/in-use stream), + # not the total pool size. A fully consumed stream returns its connection + # to the pool, where it may linger as an idle keep-alive entry until the + # server-side close is observed; that lingering idle connection is not a + # leak. Asserting on total pool size makes this racy under load (the basis + # of a CI flake); counting only non-idle connections is deterministic. + return sum(1 for conn in client._transport._pool.connections if not conn.is_idle()) + + +def _open_stream(client, port, read_timeout: Optional[float] = None): + request = client.build_request( + "GET", f"http://127.0.0.1:{port}/files", timeout=httpx.Timeout(5.0) + ) + if read_timeout is not None: + # Mirror the SDK: the per-chunk `read` timeout bounds idle gaps. + request.extensions["timeout"]["read"] = read_timeout + return client.send(request, stream=True) + + +def test_sync_full_consume_releases_connection(): + with httpx.Client() as client: + port = _start_chunked_server() + reader = FileStreamReader(_open_stream(client, port)) + assert b"".join(reader) == EXPECTED + assert _active_connections(client) == 0 + + +def test_sync_context_manager_releases_on_exit(): + with httpx.Client() as client: + port = _start_chunked_server() + with FileStreamReader(_open_stream(client, port)) as reader: + assert next(iter(reader)) == CHUNKS[0] + # Exiting the context releases the connection even though the stream + # was only partially consumed. + assert _active_connections(client) == 0 + + +def test_sync_close_is_idempotent(): + with httpx.Client() as client: + port = _start_chunked_server() + reader = FileStreamReader(_open_stream(client, port)) + reader.close() + reader.close() + assert _active_connections(client) == 0 + + +def test_sync_idle_timeout_releases_connection(): + with httpx.Client() as client: + # The server stalls before the second chunk for longer than the + # reader's idle (read) timeout. + port = _start_chunked_server(stall_before=1, stall_seconds=0.5) + reader = FileStreamReader(_open_stream(client, port, read_timeout=0.05)) + it = iter(reader) + assert next(it) + # The stalled read trips the idle timeout, which propagates and + # releases the connection. + with pytest.raises(httpx.ReadTimeout): + next(it) + assert _active_connections(client) == 0 + + +def test_sync_slow_consumer_does_not_trip_idle_timeout(): + # The server sends every chunk promptly; the consumer then pauses far + # longer than the read (idle) timeout between iterations. httpx's read + # timeout only counts while it's waiting on the wire, so a slow consumer + # must not trip it (parity with the JS wire-only idle timeout). + with httpx.Client() as client: + port = _start_chunked_server() + reader = FileStreamReader(_open_stream(client, port, read_timeout=0.05)) + out = [] + for chunk in reader: + out.append(chunk) + time.sleep(0.2) + assert b"".join(out) == EXPECTED + + +def test_sync_abandoned_reader_is_reclaimed_on_client_close(): + client = httpx.Client() + port = _start_chunked_server() + reader = FileStreamReader(_open_stream(client, port)) + assert _active_connections(client) == 1 + + # The sync reader has no GC safety net: dropping it without closing keeps + # the connection checked out (an idle timeout would reclaim a stalled one). + del reader + assert _active_connections(client) == 1 + + # Closing the client reclaims the abandoned connection. + client.close() + assert _active_connections(client) == 0 + + +async def test_async_full_consume_releases_connection(): + async with httpx.AsyncClient() as client: + port = _start_chunked_server() + request = client.build_request("GET", f"http://127.0.0.1:{port}/files") + reader = AsyncFileStreamReader(await client.send(request, stream=True)) + collected = b"".join([chunk async for chunk in reader]) + assert collected == EXPECTED + assert _active_connections(client) == 0 + + +async def test_async_context_manager_releases_on_exit(): + async with httpx.AsyncClient() as client: + port = _start_chunked_server() + request = client.build_request("GET", f"http://127.0.0.1:{port}/files") + async with AsyncFileStreamReader( + await client.send(request, stream=True) + ) as reader: + assert await reader.__anext__() == CHUNKS[0] + assert _active_connections(client) == 0 + + +async def test_async_aclose_is_idempotent(): + async with httpx.AsyncClient() as client: + port = _start_chunked_server() + request = client.build_request("GET", f"http://127.0.0.1:{port}/files") + reader = AsyncFileStreamReader(await client.send(request, stream=True)) + await reader.aclose() + await reader.aclose() + assert _active_connections(client) == 0 + + +async def test_async_idle_timeout_releases_connection(): + async with httpx.AsyncClient() as client: + port = _start_chunked_server(stall_before=1, stall_seconds=0.5) + request = client.build_request( + "GET", f"http://127.0.0.1:{port}/files", timeout=httpx.Timeout(5.0) + ) + request.extensions["timeout"]["read"] = 0.05 + reader = AsyncFileStreamReader(await client.send(request, stream=True)) + it = reader.__aiter__() + assert await it.__anext__() + # The stalled read trips the idle timeout, which propagates and + # releases the connection. + with pytest.raises(httpx.ReadTimeout): + await it.__anext__() + assert _active_connections(client) == 0 + + +async def test_async_abandoned_reader_is_reclaimed_on_client_close(): + client = httpx.AsyncClient() + port = _start_chunked_server() + request = client.build_request("GET", f"http://127.0.0.1:{port}/files") + reader = AsyncFileStreamReader(await client.send(request, stream=True)) + assert _active_connections(client) == 1 + + # The async reader has no GC safety net: dropping it without closing keeps + # the connection checked out (releasing one requires awaiting aclose()). + del reader + await asyncio.sleep(0.05) + assert _active_connections(client) == 1 + + # Closing the client reclaims the abandoned connection. + await client.aclose() + assert _active_connections(client) == 0 + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/packages/python-sdk/tests/test_filesystem_models.py b/packages/python-sdk/tests/test_filesystem_models.py new file mode 100644 index 0000000..315d158 --- /dev/null +++ b/packages/python-sdk/tests/test_filesystem_models.py @@ -0,0 +1,89 @@ +import datetime + +from e2b.envd.filesystem import filesystem_pb2 +from e2b.sandbox.filesystem.filesystem import ( + FileType, + WriteInfo, + map_entry_info, + map_file_type_str, +) +from e2b.volume.client.models import VolumeEntryStat as VolumeEntryStatApi +from e2b.volume.client.models import VolumeEntryStatType +from e2b.volume.utils import convert_volume_entry_stat + + +def test_write_info_from_dict_converts_type_to_enum(): + info = WriteInfo.from_dict( + {"name": "a.txt", "type": "file", "path": "/home/user/a.txt"} + ) + assert info.type is FileType.FILE + + info = WriteInfo.from_dict({"name": "dir", "type": "dir", "path": "/home/user/dir"}) + assert info.type is FileType.DIR + + +def test_write_info_from_dict_handles_missing_or_unknown_type(): + info = WriteInfo.from_dict({"name": "a.txt", "path": "/home/user/a.txt"}) + assert info.type is None + + info = WriteInfo.from_dict( + {"name": "a.txt", "type": "symlink", "path": "/home/user/a.txt"} + ) + assert info.type is None + + +def test_map_file_type_str(): + assert map_file_type_str("file") is FileType.FILE + assert map_file_type_str("dir") is FileType.DIR + assert map_file_type_str("unknown") is None + assert map_file_type_str(None) is None + + +def test_map_entry_info_modified_time_is_timezone_aware(): + entry = filesystem_pb2.EntryInfo( + name="a.txt", + type=filesystem_pb2.FileType.FILE_TYPE_FILE, + path="/home/user/a.txt", + size=4, + mode=0o644, + permissions="-rw-r--r--", + owner="user", + group="user", + ) + entry.modified_time.FromDatetime( + datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc) + ) + + info = map_entry_info(entry) + + assert info.modified_time.tzinfo == datetime.timezone.utc + assert info.modified_time == datetime.datetime( + 2026, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc + ) + + +def test_convert_volume_entry_stat_normalizes_naive_times_to_utc(): + naive = datetime.datetime(2026, 1, 2, 3, 4, 5) + aware = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc) + + api_stat = VolumeEntryStatApi( + name="a.txt", + type_=VolumeEntryStatType.FILE, + path="/a.txt", + size=4, + mode=0o644, + uid=1000, + gid=1000, + atime=naive, + mtime=naive, + ctime=aware, + ) + + stat = convert_volume_entry_stat(api_stat) + + assert stat.atime == aware + assert stat.mtime == aware + assert stat.ctime == aware + assert stat.atime.tzinfo == datetime.timezone.utc + assert stat.mtime.tzinfo == datetime.timezone.utc + assert stat.ctime.tzinfo == datetime.timezone.utc diff --git a/packages/python-sdk/tests/test_io_utils.py b/packages/python-sdk/tests/test_io_utils.py new file mode 100644 index 0000000..bcb8e12 --- /dev/null +++ b/packages/python-sdk/tests/test_io_utils.py @@ -0,0 +1,74 @@ +"""Unit tests for the streamed-upload IO helpers.""" + +import asyncio +import gzip +import threading +from typing import IO, cast + +from e2b.io_utils import agzip_iter, aiter_io_chunks, gzip_iter, iter_io_chunks + + +def test_iter_io_chunks_encodes_text(): + import io + + assert list(iter_io_chunks(io.BytesIO(b"abc"))) == [b"abc"] + assert list(iter_io_chunks(io.StringIO("abc"))) == [b"abc"] + + +def test_gzip_iter_roundtrip(): + compressed = b"".join(gzip_iter([b"hello ", b"world"])) + assert gzip.decompress(compressed) == b"hello world" + + +async def test_aiter_io_chunks_roundtrip(): + import io + + chunks = [chunk async for chunk in aiter_io_chunks(io.BytesIO(b"hello"))] + assert b"".join(chunks) == b"hello" + + +async def test_agzip_iter_roundtrip(): + async def source(): + yield b"hello " + yield b"world" + + compressed = b"".join([c async for c in agzip_iter(source())]) + assert gzip.decompress(compressed) == b"hello world" + + +async def test_aiter_io_chunks_offloads_reads_to_a_thread(): + """A blocking ``read`` must not stall the event loop. + + The reader's ``read`` blocks until a concurrent task releases it; that task + can only run if the read is off the loop. If the read ran on the loop, the + releaser would never run and ``release.wait`` would time out, failing the + ``released`` assertion. + """ + started = threading.Event() + release = threading.Event() + result = {"released": None} + + class BlockingReader: + def __init__(self): + self._done = False + + def read(self, _n): + if self._done: + return b"" + started.set() + result["released"] = release.wait(2) + self._done = True + return b"data" + + async def releaser(): + while not started.is_set(): + await asyncio.sleep(0.01) + release.set() + + async def collect(): + reader = cast(IO, BlockingReader()) + return [chunk async for chunk in aiter_io_chunks(reader)] + + _, chunks = await asyncio.gather(releaser(), collect()) + assert result["released"] is True + assert chunks == [b"data"] diff --git a/packages/python-sdk/tests/test_logging_option.py b/packages/python-sdk/tests/test_logging_option.py new file mode 100644 index 0000000..dcc39d0 --- /dev/null +++ b/packages/python-sdk/tests/test_logging_option.py @@ -0,0 +1,153 @@ +import inspect +import logging + +import e2b_connect as connect +from e2b import AsyncSandbox, ConnectionConfig, Sandbox +from e2b.api import ( + ApiClient, + make_async_logging_event_hooks, + make_logging_event_hooks, +) +from e2b.connection_config import ApiParams +from e2b.volume.connection_config import VolumeConnectionConfig + + +def test_connection_config_stores_logger(): + custom = logging.getLogger("test.custom") + config = ConnectionConfig(api_key="e2b_" + "0" * 40, logger=custom) + assert config.logger is custom + + +def test_connection_config_logger_defaults_to_none(): + config = ConnectionConfig(api_key="e2b_" + "0" * 40) + assert config.logger is None + + +def test_logger_is_not_a_public_per_request_api_param(): + # Matching the JS SDK, `logger` is a construction-time option (Sandbox.create + # / connect), not a public per-request ApiParams field that control-plane + # methods like kill/list/get_info accept from the caller. + assert "logger" not in ApiParams.__annotations__ + + +def test_get_api_params_propagates_stored_logger(): + # Instance control-plane methods (kill, pause, set_timeout, get_info, + # connect) rebuild a throwaway ConnectionConfig from these params, so the + # logger the sandbox was created/connected with must survive the round-trip. + custom = logging.getLogger("test.propagate") + config = ConnectionConfig(api_key="e2b_" + "0" * 40, logger=custom) + assert config.get_api_params()["logger"] is custom + assert ConnectionConfig(**config.get_api_params()).logger is custom + + no_logger = ConnectionConfig(api_key="e2b_" + "0" * 40) + assert no_logger.get_api_params()["logger"] is None + + +def test_logger_is_accepted_on_create_and_connect(): + for cls in (Sandbox, AsyncSandbox): + assert "logger" in inspect.signature(cls.create).parameters + # `logger` is a construction option, so it is accepted by the static + # `Sandbox.connect(sandbox_id, ...)` form (which builds a fresh instance) + # but not by instance `sandbox.connect()`, where the already-built clients + # cannot adopt a new logger. + assert "logger" not in inspect.signature(Sandbox.connect).parameters + assert "logger" not in inspect.signature(AsyncSandbox.connect).parameters + + +def test_volume_connection_config_stores_and_round_trips_logger(): + custom = logging.getLogger("test.volume") + config = VolumeConnectionConfig(token="token", logger=custom) + assert config.logger is custom + assert config.get_api_params()["logger"] is custom + + +def test_api_client_uses_config_logger(): + custom = logging.getLogger("test.api-client") + config = ConnectionConfig(api_key="e2b_" + "0" * 40, logger=custom) + client = ApiClient(config) + try: + assert client._logger is custom + finally: + client.get_httpx_client().close() + + +def test_api_client_without_logger_emits_no_hooks(): + # With no logger supplied, nothing should be logged (matching the JS SDK, + # which only attaches its logging middleware when a logger is given). + config = ConnectionConfig(api_key="e2b_" + "0" * 40) + client = ApiClient(config) + try: + assert client._logger is None + assert client.get_httpx_client().event_hooks == { + "request": [], + "response": [], + } + finally: + client.get_httpx_client().close() + + +def test_rpc_client_without_logger_does_not_log(caplog): + client = connect.Client(url="https://example.com", response_type=object) + assert client._logger is None + # The guarded helpers must be safe no-ops when no logger was supplied. + with caplog.at_level(logging.DEBUG): + client._log_request() + client._log_response(200) + client._log_response(500) + client._log_stream_message() + assert caplog.records == [] + + +def test_rpc_client_uses_provided_logger(caplog): + custom = logging.getLogger("test.rpc") + client = connect.Client( + url="https://example.com", response_type=object, logger=custom + ) + assert client._logger is custom + + with caplog.at_level(logging.DEBUG, logger="test.rpc"): + client._log_request() + client._log_response(200) + client._log_response(500) + client._log_stream_message() + + levels = [(r.levelno, r.getMessage()) for r in caplog.records] + assert (logging.INFO, "Request: POST https://example.com") in levels + assert (logging.INFO, "Response: 200 https://example.com") in levels + assert (logging.ERROR, "Response: 500 https://example.com") in levels + assert (logging.DEBUG, "Response stream: https://example.com") in levels + + +def test_logging_event_hooks_without_logger_are_empty(): + assert make_logging_event_hooks(None) == {} + assert make_async_logging_event_hooks(None) == {} + + +def test_sync_logging_event_hooks_emit_records(caplog): + log = logging.getLogger("test.hooks.sync") + hooks = make_logging_event_hooks(log) + + class _Req: + method = "GET" + url = "https://example.com/foo" + + class _Resp: + def __init__(self, status_code): + self.status_code = status_code + + with caplog.at_level(logging.DEBUG, logger="test.hooks.sync"): + hooks["request"][0](_Req()) + hooks["response"][0](_Resp(200)) + hooks["response"][0](_Resp(500)) + + levels = [(r.levelno, r.getMessage()) for r in caplog.records] + assert (logging.INFO, "Request GET https://example.com/foo") in levels + assert (logging.INFO, "Response 200") in levels + assert (logging.ERROR, "Response 500") in levels + + +def test_make_async_logging_event_hooks_shape(): + hooks = make_async_logging_event_hooks(logging.getLogger("test.hooks.async")) + assert set(hooks) == {"request", "response"} + assert len(hooks["request"]) == 1 + assert len(hooks["response"]) == 1 diff --git a/packages/python-sdk/tests/test_paginator.py b/packages/python-sdk/tests/test_paginator.py new file mode 100644 index 0000000..e667f8e --- /dev/null +++ b/packages/python-sdk/tests/test_paginator.py @@ -0,0 +1,53 @@ +import pytest + +from e2b.connection_config import ApiParams +from e2b.paginator import PaginatorBase + + +class FakePaginator(PaginatorBase[str, ApiParams]): + """Minimal paginator that returns canned pages and drives the shared state.""" + + def __init__(self, pages): + super().__init__() + self._pages = pages + self._call = 0 + + def next_items(self): + if not self.has_next: + raise Exception("No more items to fetch") + + items, headers = self._pages[self._call] + self._call += 1 + self._update_pagination(headers) + return items + + +def test_paginator_exposes_state_and_advances(): + paginator = FakePaginator( + [ + (["a", "b"], {"x-next-token": "tok-2"}), + (["c"], {}), + ] + ) + + assert paginator.has_next is True + assert paginator.next_token is None + + first = paginator.next_items() + assert first == ["a", "b"] + assert paginator.has_next is True + assert paginator.next_token == "tok-2" + + second = paginator.next_items() + assert second == ["c"] + assert paginator.has_next is False + assert paginator.next_token is None + + +def test_paginator_raises_once_exhausted(): + paginator = FakePaginator([([], {})]) + paginator.next_items() + + assert paginator.has_next is False + with pytest.raises(Exception, match="No more items to fetch"): + paginator.next_items() diff --git a/packages/python-sdk/tests/test_sandbox_urls.py b/packages/python-sdk/tests/test_sandbox_urls.py new file mode 100644 index 0000000..f239ced --- /dev/null +++ b/packages/python-sdk/tests/test_sandbox_urls.py @@ -0,0 +1,72 @@ +import time +import urllib.parse + +import pytest +from packaging.version import Version + +from e2b.connection_config import ConnectionConfig +from e2b.exceptions import InvalidArgumentException +from e2b.sandbox.main import SandboxBase +from e2b.sandbox.signature import get_signature + + +def create_sandbox(envd_access_token=None): + return SandboxBase( + sandbox_id="sandbox-id", + sandbox_domain="e2b.app", + envd_version=Version("0.4.0"), + envd_access_token=envd_access_token, + traffic_access_token=None, + connection_config=ConnectionConfig(domain="e2b.app"), + ) + + +def test_file_urls_use_direct_sandbox_host_when_envd_api_uses_stable_host(): + sandbox = create_sandbox() + + assert sandbox.envd_api_url == "https://sandbox.e2b.app" + assert sandbox.envd_direct_url == "https://49983-sandbox-id.e2b.app" + assert ( + sandbox.download_url("/tmp/a.txt") + == "https://49983-sandbox-id.e2b.app/files?path=%2Ftmp%2Fa.txt" + ) + assert ( + sandbox.upload_url("/tmp/a.txt") + == "https://49983-sandbox-id.e2b.app/files?path=%2Ftmp%2Fa.txt" + ) + + +def test_file_urls_raise_when_signature_expiration_used_on_unsecured_sandbox(): + sandbox = create_sandbox(envd_access_token=None) + + with pytest.raises( + InvalidArgumentException, + match="Signature expiration can be used only when sandbox is created as secured.", + ): + sandbox.download_url("/tmp/a.txt", use_signature_expiration=120) + + with pytest.raises( + InvalidArgumentException, + match="Signature expiration can be used only when sandbox is created as secured.", + ): + sandbox.upload_url("/tmp/a.txt", use_signature_expiration=120) + + +def test_zero_signature_expiration_expires_immediately(): + before = int(time.time()) + signature = get_signature("/tmp/a.txt", "read", "user", "access-token", 0) + after = int(time.time()) + + assert signature["expiration"] is not None + assert before <= signature["expiration"] <= after + + +def test_zero_signature_expiration_is_included_in_url(): + sandbox = create_sandbox(envd_access_token="access-token") + + for url in ( + sandbox.download_url("/tmp/a.txt", use_signature_expiration=0), + sandbox.upload_url("/tmp/a.txt", use_signature_expiration=0), + ): + query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) + assert "signature_expiration" in query diff --git a/packages/python-sdk/tests/test_validate_api_key.py b/packages/python-sdk/tests/test_validate_api_key.py new file mode 100644 index 0000000..4e23b37 --- /dev/null +++ b/packages/python-sdk/tests/test_validate_api_key.py @@ -0,0 +1,58 @@ +import pytest + +from e2b import ConnectionConfig +from e2b.api import ApiClient, validate_api_key +from e2b.exceptions import AuthenticationException + + +def test_accepts_well_formed_key(test_api_key): + validate_api_key(test_api_key) + + +def test_rejects_missing_prefix(): + with pytest.raises(AuthenticationException, match=r"Invalid API key format"): + validate_api_key("sk_" + "0" * 40) + + +def test_accepts_non_default_body_length(): + validate_api_key("e2b_" + "0" * 20) + + +def test_rejects_empty_body(): + with pytest.raises(AuthenticationException, match=r"Invalid API key format"): + validate_api_key("e2b_") + + +def test_rejects_non_hex_body(): + with pytest.raises(AuthenticationException, match=r"Invalid API key format"): + validate_api_key("e2b_" + "z" * 40) + + +def test_rejects_trailing_newline(test_api_key): + with pytest.raises(AuthenticationException, match=r"Invalid API key format"): + validate_api_key(test_api_key + "\n") + + +def test_error_message_includes_example_token(test_api_key): + with pytest.raises(AuthenticationException) as exc_info: + validate_api_key("nope") + assert test_api_key in str(exc_info.value) + + +def test_api_client_validates_key_by_default(): + config = ConnectionConfig(api_key="not-a-valid-key") + with pytest.raises(AuthenticationException, match=r"Invalid API key format"): + ApiClient(config) + + +def test_api_client_skips_validation_when_disabled(): + config = ConnectionConfig(api_key="not-a-valid-key", validate_api_key=False) + # Should not raise: validation is disabled. + ApiClient(config) + + +def test_api_client_requires_api_key(monkeypatch): + monkeypatch.delenv("E2B_API_KEY", raising=False) + config = ConnectionConfig() + with pytest.raises(AuthenticationException, match=r"API key is required"): + ApiClient(config) diff --git a/packages/python-sdk/tests/test_volume_client.py b/packages/python-sdk/tests/test_volume_client.py new file mode 100644 index 0000000..c3e7c23 --- /dev/null +++ b/packages/python-sdk/tests/test_volume_client.py @@ -0,0 +1,145 @@ +import asyncio +import gc +import threading + +import httpx +import pytest + +from e2b.exceptions import AuthenticationException +from e2b.volume.client_async import ( + AsyncTransportWithLogger as AsyncVolumeTransport, + get_api_client as get_async_api_client, + get_transport as get_async_transport, +) +from e2b.volume.client_sync import ( + get_api_client as get_sync_api_client, + get_transport as get_sync_transport, +) +from e2b.volume.connection_config import VolumeConnectionConfig + + +def test_sync_client_requires_volume_token(monkeypatch): + monkeypatch.setenv("E2B_ACCESS_TOKEN", "env-access-token") + + with pytest.raises(AuthenticationException): + get_sync_api_client(VolumeConnectionConfig()) + + +def test_async_client_requires_volume_token(monkeypatch): + monkeypatch.setenv("E2B_ACCESS_TOKEN", "env-access-token") + + with pytest.raises(AuthenticationException): + get_async_api_client(VolumeConnectionConfig()) + + +def test_sync_client_uses_config_request_timeout(): + client = get_sync_api_client(VolumeConnectionConfig(token="vol-token")) + assert client.get_httpx_client().timeout == httpx.Timeout(60.0) + + client = get_sync_api_client( + VolumeConnectionConfig(token="vol-token", request_timeout=10.0) + ) + assert client.get_httpx_client().timeout == httpx.Timeout(10.0) + + client = get_sync_api_client( + VolumeConnectionConfig(token="vol-token", request_timeout=0) + ) + assert client.get_httpx_client().timeout == httpx.Timeout(None) + + +def test_async_client_uses_config_request_timeout(): + async def run(): + client = get_async_api_client(VolumeConnectionConfig(token="vol-token")) + assert client.get_async_httpx_client().timeout == httpx.Timeout(60.0) + + client = get_async_api_client( + VolumeConnectionConfig(token="vol-token", request_timeout=0) + ) + assert client.get_async_httpx_client().timeout == httpx.Timeout(None) + + asyncio.run(run()) + + +def test_sync_transport_is_cached_per_proxy(): + config = VolumeConnectionConfig(token="vol-token") + proxied = VolumeConnectionConfig(token="vol-token", proxy="http://127.0.0.1:8080") + + transport_a = get_sync_transport(config) + transport_b = get_sync_transport(config) + transport_c = get_sync_transport(proxied) + + assert transport_a is transport_b + assert transport_a is not transport_c + + +def test_sync_transport_is_not_shared_across_threads(): + config = VolumeConnectionConfig(token="vol-token") + main_transport = get_sync_transport(config) + + result = {} + + def worker(): + result["transport"] = get_sync_transport(config) + + thread = threading.Thread(target=worker) + thread.start() + thread.join() + + assert result["transport"] is not main_transport + + +def test_async_transport_is_cached_per_event_loop(): + config = VolumeConnectionConfig(token="vol-token") + proxied = VolumeConnectionConfig(token="vol-token", proxy="http://127.0.0.1:8080") + + async def get_transports(): + return get_async_transport(config), get_async_transport(config) + + async def get_proxied_transport(): + return get_async_transport(proxied) + + loop_a = asyncio.new_event_loop() + loop_b = asyncio.new_event_loop() + try: + transport_a1, transport_a2 = loop_a.run_until_complete(get_transports()) + transport_b1, _ = loop_b.run_until_complete(get_transports()) + proxied_a = loop_a.run_until_complete(get_proxied_transport()) + + # Same loop reuses the transport, another loop gets its own + assert transport_a1 is transport_a2 + assert transport_a1 is not transport_b1 + + # Different proxy gets its own transport even on the same loop + assert proxied_a is not transport_a1 + finally: + loop_a.close() + loop_b.close() + + +def test_async_transport_not_reused_across_sequential_loops(): + AsyncVolumeTransport._instances.clear() + config = VolumeConnectionConfig(token="vol-token") + + async def get_transport(): + return get_async_transport(config) + + loop_a = asyncio.new_event_loop() + try: + transport_a = loop_a.run_until_complete(get_transport()) + finally: + loop_a.close() + del loop_a + gc.collect() + + # The cache entry dies with the loop, so a later loop can never inherit + # a transport bound to a closed loop, even when CPython reuses the dead + # loop's object id. + assert len(AsyncVolumeTransport._instances) == 0 + + loop_b = asyncio.new_event_loop() + try: + transport_b = loop_b.run_until_complete(get_transport()) + finally: + loop_b.close() + + assert transport_b is not transport_a diff --git a/packages/python-sdk/tests/test_volume_connection_config.py b/packages/python-sdk/tests/test_volume_connection_config.py new file mode 100644 index 0000000..61b5484 --- /dev/null +++ b/packages/python-sdk/tests/test_volume_connection_config.py @@ -0,0 +1,91 @@ +from e2b.volume.connection_config import VolumeConnectionConfig + + +def test_volume_api_url_defaults_correctly(monkeypatch): + monkeypatch.delenv("E2B_VOLUME_API_URL", raising=False) + monkeypatch.delenv("E2B_DOMAIN", raising=False) + monkeypatch.delenv("E2B_DEBUG", raising=False) + + config = VolumeConnectionConfig() + assert config.api_url == "https://api.e2b.app" + + +def test_volume_api_url_in_args(): + config = VolumeConnectionConfig(api_url="http://localhost:8080") + assert config.api_url == "http://localhost:8080" + + +def test_volume_api_url_in_env_var(monkeypatch): + monkeypatch.setenv("E2B_VOLUME_API_URL", "http://localhost:8080") + + config = VolumeConnectionConfig() + assert config.api_url == "http://localhost:8080" + + +def test_volume_api_url_has_correct_priority(monkeypatch): + monkeypatch.setenv("E2B_VOLUME_API_URL", "http://localhost:1111") + + config = VolumeConnectionConfig(api_url="http://localhost:8080") + assert config.api_url == "http://localhost:8080" + + +def test_volume_api_url_debug_mode(monkeypatch): + monkeypatch.delenv("E2B_VOLUME_API_URL", raising=False) + monkeypatch.setenv("E2B_DEBUG", "true") + + config = VolumeConnectionConfig() + assert config.api_url == "http://localhost:8080" + + +def test_volume_api_url_custom_domain(monkeypatch): + monkeypatch.delenv("E2B_VOLUME_API_URL", raising=False) + monkeypatch.setenv("E2B_DOMAIN", "custom.com") + + config = VolumeConnectionConfig() + assert config.api_url == "https://api.custom.com" + + +def test_volume_api_url_custom_domain_in_args(): + config = VolumeConnectionConfig(domain="custom.com") + assert config.api_url == "https://api.custom.com" + + +def test_volume_token_does_not_fall_back_to_access_token_env(monkeypatch): + monkeypatch.setenv("E2B_ACCESS_TOKEN", "env-access-token") + + config = VolumeConnectionConfig() + assert config.token is None + assert config.access_token is None + + +def test_volume_token_in_args(monkeypatch): + monkeypatch.setenv("E2B_ACCESS_TOKEN", "env-access-token") + + config = VolumeConnectionConfig(token="vol-token") + assert config.token == "vol-token" + assert config.access_token == "vol-token" + + +def test_volume_config_does_not_mutate_caller_headers(): + headers = {"X-Custom": "value"} + + config = VolumeConnectionConfig(headers=headers) + + assert headers == {"X-Custom": "value"} + assert config.headers["X-Custom"] == "value" + assert "User-Agent" in config.headers + + +def test_volume_request_timeout_defaults_to_60_seconds(): + config = VolumeConnectionConfig() + assert config.request_timeout == 60.0 + + +def test_volume_request_timeout_in_args(): + config = VolumeConnectionConfig(request_timeout=10.0) + assert config.request_timeout == 10.0 + + +def test_volume_request_timeout_zero_disables_timeout(): + config = VolumeConnectionConfig(request_timeout=0) + assert config.request_timeout is None diff --git a/packages/python-sdk/tests/test_watch_handle.py b/packages/python-sdk/tests/test_watch_handle.py new file mode 100644 index 0000000..2e49f48 --- /dev/null +++ b/packages/python-sdk/tests/test_watch_handle.py @@ -0,0 +1,214 @@ +import asyncio + +from packaging.version import Version + +from e2b.connection_config import ConnectionConfig +from e2b.envd.filesystem import filesystem_pb2 +from e2b.envd.versions import ENVD_DEFAULT_USER +from e2b.sandbox_async.filesystem.watch_handle import AsyncWatchHandle +from e2b.sandbox_sync.filesystem.watch_handle import WatchHandle + + +def _fs_event( + name: str, + event_type=filesystem_pb2.EventType.EVENT_TYPE_WRITE, +) -> filesystem_pb2.WatchDirResponse: + return filesystem_pb2.WatchDirResponse( + filesystem=filesystem_pb2.FilesystemEvent(name=name, type=event_type) + ) + + +# --- Sync WatchHandle: request timeout + auth header (bug 28) --- + + +class _FakeSyncRpc: + def __init__(self): + self.calls = [] + + def get_watcher_events(self, req, **opts): + self.calls.append(("get_watcher_events", req, opts)) + return filesystem_pb2.GetWatcherEventsResponse(events=[]) + + def remove_watcher(self, req, **opts): + self.calls.append(("remove_watcher", req, opts)) + return filesystem_pb2.RemoveWatcherResponse() + + +def _make_sync_handle(rpc, envd_version: Version, user=None) -> WatchHandle: + return WatchHandle( + get_rpc=lambda: rpc, + watcher_id="watcher-1", + connection_config=ConnectionConfig(), + envd_version=envd_version, + user=user, + ) + + +def test_sync_get_new_events_passes_request_timeout_and_auth_header(): + rpc = _FakeSyncRpc() + # envd < 0.4.0 has no default user, so the auth header must be sent. + handle = _make_sync_handle(rpc, Version("0.3.0")) + + handle.get_new_events() + + name, _, opts = rpc.calls[0] + assert name == "get_watcher_events" + # A request timeout is always supplied so a stalled call can't hang forever. + assert opts["request_timeout"] == 60.0 + assert opts["headers"].get("Authorization", "").startswith("Basic ") + + +def test_sync_stop_passes_request_timeout_and_auth_header(): + rpc = _FakeSyncRpc() + handle = _make_sync_handle(rpc, Version("0.3.0")) + + handle.stop() + + name, _, opts = rpc.calls[0] + assert name == "remove_watcher" + assert opts["request_timeout"] == 60.0 + assert opts["headers"].get("Authorization", "").startswith("Basic ") + + +def test_sync_caller_supplied_request_timeout_is_forwarded(): + rpc = _FakeSyncRpc() + handle = _make_sync_handle(rpc, ENVD_DEFAULT_USER) + + handle.get_new_events(request_timeout=5) + handle.stop(request_timeout=7) + + assert rpc.calls[0][2]["request_timeout"] == 5 + assert rpc.calls[1][2]["request_timeout"] == 7 + # No explicit user on a recent envd → no auth header forced. + assert "Authorization" not in rpc.calls[0][2]["headers"] + + +# --- Async WatchHandle: on_exit lifecycle (bug 29) --- + + +async def test_async_on_exit_fires_with_none_on_clean_end(): + async def events(): + yield _fs_event("a.txt") + + received = [] + exit_calls = [] + + handle = AsyncWatchHandle( + events=events(), + on_event=received.append, + on_exit=exit_calls.append, + ) + + await handle._wait + + assert [e.name for e in received] == ["a.txt"] + assert exit_calls == [None] + + +async def test_async_on_exit_fires_with_error_on_stream_error(): + error = RuntimeError("stream died") + + async def events(): + raise error + yield # pragma: no cover - makes this an async generator + + exit_calls = [] + + handle = AsyncWatchHandle( + events=events(), + on_event=lambda e: None, + on_exit=exit_calls.append, + ) + + await handle._wait + + assert exit_calls == [error] + + +async def test_async_on_exit_fires_on_stop(): + started = asyncio.Event() + + async def events(): + started.set() + await asyncio.Event().wait() # block until cancelled by stop() + yield # pragma: no cover - never reached + + exit_calls = [] + + handle = AsyncWatchHandle( + events=events(), + on_event=lambda e: None, + on_exit=exit_calls.append, + ) + + await started.wait() + await handle.stop() + + assert exit_calls == [None] + + +async def test_async_on_exit_awaits_async_callback(): + async def events(): + yield _fs_event("a.txt") + + exit_calls = [] + + async def on_exit(err): + exit_calls.append(err) + + handle = AsyncWatchHandle( + events=events(), + on_event=lambda e: None, + on_exit=on_exit, + ) + + await handle._wait + + assert exit_calls == [None] + + +async def test_async_on_exit_awaits_async_callback_on_stop(): + started = asyncio.Event() + + async def events(): + started.set() + await asyncio.Event().wait() # block until cancelled by stop() + yield # pragma: no cover - never reached + + exit_done = [] + + async def on_exit(err): + # A real suspension proves the cancellation path drives the async + # callback to completion rather than dropping it mid-await. + await asyncio.sleep(0) + exit_done.append(err) + + handle = AsyncWatchHandle( + events=events(), + on_event=lambda e: None, + on_exit=on_exit, + ) + + await started.wait() + await handle.stop() + + assert exit_done == [None] + + +async def test_async_on_exit_error_does_not_leak(): + async def events(): + yield _fs_event("a.txt") + + async def on_exit(err): + raise RuntimeError("on_exit failed") + + handle = AsyncWatchHandle( + events=events(), + on_event=lambda e: None, + on_exit=on_exit, + ) + + # A raising on_exit must not surface as an unretrieved task exception. + await handle._wait + assert handle._wait.done() + assert handle._wait.exception() is None diff --git a/packages/python-sdk/uv.lock b/packages/python-sdk/uv.lock new file mode 100644 index 0000000..bc69db8 --- /dev/null +++ b/packages/python-sdk/uv.lock @@ -0,0 +1,1160 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "argcomplete" +version = "3.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "black" +version = "26.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/a8/11170031095655d36ebc6664fe0897866f6023892396900eec0e8fdc4299/black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2", size = 1866562, upload-time = "2026-03-12T03:39:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/9e7548d719c3248c6c2abfd555d11169457cbd584d98d179111338423790/black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b", size = 1703623, upload-time = "2026-03-12T03:40:00.347Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac", size = 1768388, upload-time = "2026-03-12T03:40:01.765Z" }, + { url = "https://files.pythonhosted.org/packages/52/79/c1ee726e221c863cde5164f925bacf183dfdf0397d4e3f94889439b947b4/black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a", size = 1412969, upload-time = "2026-03-12T03:40:03.252Z" }, + { url = "https://files.pythonhosted.org/packages/73/a5/15c01d613f5756f68ed8f6d4ec0a1e24b82b18889fa71affd3d1f7fad058/black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a", size = 1220345, upload-time = "2026-03-12T03:40:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, + { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, + { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, +] + +[[package]] +name = "bracex" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/f5/4473ad9b48cd0420a2d762a3750fa0e078e23e060b1af72662e5987e5530/bracex-3.0.tar.gz", hash = "sha256:b73f718d6bd98d8419e45df02426c86e9967c179949f779340d6c3a8c83b9111", size = 43162, upload-time = "2026-06-30T00:43:35.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/2e/68781b78e764e5ccc4af1e3d27e060069c73af90234853fa80000e7ee79d/bracex-3.0-py3-none-any.whl", hash = "sha256:3833e61c2f092d5aa0468fa2e6c6e990a306185abf763b6d122f0158e59c58a5", size = 11738, upload-time = "2026-06-30T00:43:34.196Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "datamodel-code-generator" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "black" }, + { name = "genson" }, + { name = "inflect" }, + { name = "isort" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/e3/b973eb85c33cfba21e34f936edc1b93d97829fe1da7ad9a4d3402958e9ec/datamodel_code_generator-0.34.0.tar.gz", hash = "sha256:4695bdd2c9e85049db4bdf5791f68647518d98fd589d30bd8525e941e628acf7", size = 459903, upload-time = "2025-09-28T06:36:41.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/fa/4087f3dcb50d193c2229b8d69817a09296ae3f2dcb9d615c7c51ec47c57f/datamodel_code_generator-0.34.0-py3-none-any.whl", hash = "sha256:74d1aaf2ab27e21b6d6e28b5236f27271b8404b7fd0e856be95c2f7562d694ff", size = 121388, upload-time = "2025-09-28T06:36:38.953Z" }, +] + +[[package]] +name = "dockerfile-parse" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc", size = 24556, upload-time = "2023-07-18T13:36:07.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6", size = 14845, upload-time = "2023-07-18T13:36:06.052Z" }, +] + +[[package]] +name = "e2b" +version = "2.31.0" +source = { editable = "." } +dependencies = [ + { name = "attrs" }, + { name = "dockerfile-parse" }, + { name = "h2" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "python-dateutil" }, + { name = "rich" }, + { name = "typing-extensions" }, + { name = "wcmatch" }, +] + +[package.dev-dependencies] +codegen = [ + { name = "black" }, + { name = "datamodel-code-generator" }, + { name = "e2b-openapi-python-client" }, + { name = "pyyaml" }, +] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-dotenv" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist" }, + { name = "python-dotenv" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "attrs", specifier = ">=23.2.0" }, + { name = "dockerfile-parse", specifier = ">=2.0.1,<3" }, + { name = "h2", specifier = ">=4,<5" }, + { name = "httpcore", specifier = ">=1.0.5,<2" }, + { name = "httpx", specifier = ">=0.27.0,<1.0.0" }, + { name = "packaging", specifier = ">=24.1" }, + { name = "protobuf", specifier = ">=4.21.0" }, + { name = "python-dateutil", specifier = ">=2.8.2" }, + { name = "rich", specifier = ">=14.0.0" }, + { name = "typing-extensions", specifier = ">=4.1.0" }, + { name = "wcmatch", specifier = ">=10.1,<11" }, +] + +[package.metadata.requires-dev] +codegen = [ + { name = "black", specifier = "==26.3.1" }, + { name = "datamodel-code-generator", specifier = "==0.34.0" }, + { name = "e2b-openapi-python-client", specifier = "==0.26.2" }, + { name = "pyyaml", specifier = "==6.0.2" }, +] +dev = [ + { name = "pytest", specifier = ">=9.0.3,<10" }, + { name = "pytest-asyncio", specifier = ">=1.3.0,<2" }, + { name = "pytest-dotenv", specifier = ">=0.5.2,<0.6" }, + { name = "pytest-timeout", specifier = ">=2.4.0,<3" }, + { name = "pytest-xdist", specifier = ">=3.3.1,<4" }, + { name = "python-dotenv", specifier = ">=1.0.0,<2" }, + { name = "ruff", specifier = ">=0.11.12,<0.12" }, + { name = "ty", specifier = ">=0.0.15,<0.0.16" }, +] + +[[package]] +name = "e2b-openapi-python-client" +version = "0.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "ruamel-yaml" }, + { name = "ruff" }, + { name = "shellingham" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/68/42fe869ac0f83e060d639b0441980932b2fa68bab85444161111af25d6a9/e2b_openapi_python_client-0.26.2.tar.gz", hash = "sha256:8b71507f0096e94fd836f240a62cfae42a85d393437acf13fb9eef81e23f3891", size = 125758, upload-time = "2025-08-10T15:54:27.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/f3/db8b73c07fe3f6a88ba53bdc4b841a6c20e477ec8e7a96ae05ced3245276/e2b_openapi_python_client-0.26.2-py3-none-any.whl", hash = "sha256:15ba028202879184589e9df54b1405a0925ad07514c1f0476eca6c278caed3e7", size = 183327, upload-time = "2025-08-10T15:54:25.538Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "genson" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/cf/2303c8ad276dcf5ee2ad6cf69c4338fd86ef0f471a5207b069adf7a393cf/genson-1.3.0.tar.gz", hash = "sha256:e02db9ac2e3fd29e65b5286f7135762e2cd8a986537c075b06fc5f1517308e37", size = 34919, upload-time = "2024-05-15T22:08:49.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/5c/e226de133afd8bb267ec27eead9ae3d784b95b39a287ed404caab39a5f50/genson-1.3.0-py3-none-any.whl", hash = "sha256:468feccd00274cc7e4c09e84b08704270ba8d95232aa280f65b986139cec67f7", size = 21470, upload-time = "2024-05-15T22:08:47.056Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "inflect" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197, upload-time = "2024-12-28T17:11:15.931Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/82/fa43935523efdfcce6abbae9da7f372b627b27142c3419fcf13bf5b0c397/isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481", size = 824325, upload-time = "2025-10-01T16:26:45.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/cc/9b681a170efab4868a032631dea1e8446d8ec718a7f657b94d49d1a12643/isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784", size = 94329, upload-time = "2025-10-01T16:26:43.291Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-dotenv" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/b0/cafee9c627c1bae228eb07c9977f679b3a7cb111b488307ab9594ba9e4da/pytest-dotenv-0.5.2.tar.gz", hash = "sha256:2dc6c3ac6d8764c71c6d2804e902d0ff810fa19692e95fe138aefc9b1aa73732", size = 3782, upload-time = "2020-06-16T12:38:03.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/da/9da67c67b3d0963160e3d2cbc7c38b6fae342670cc8e6d5936644b2cf944/pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f", size = 3993, upload-time = "2020-06-16T12:38:01.139Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.18.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.15' and platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/2b/7a1f1ebcd6b3f14febdc003e658778d81e76b40df2267904ee6b13f0c5c6/ruamel_yaml-0.18.17.tar.gz", hash = "sha256:9091cd6e2d93a3a4b157ddb8fabf348c3de7f1fb1381346d985b6b247dcd8d3c", size = 149602, upload-time = "2025-12-17T20:02:55.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/fe/b6045c782f1fd1ae317d2a6ca1884857ce5c20f59befe6ab25a8603c43a7/ruamel_yaml-0.18.17-py3-none-any.whl", hash = "sha256:9c8ba9eb3e793efdf924b60d521820869d5bf0cb9c6f1b82d82de8295e290b9d", size = 121594, upload-time = "2025-12-17T20:02:07.657Z" }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/5a/4ab767cd42dcd65b83c323e1620d7c01ee60a52f4032fb7b61501f45f5c2/ruamel_yaml_clib-0.2.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:88eea8baf72f0ccf232c22124d122a7f26e8a24110a0273d9bcddcb0f7e1fa03", size = 147454, upload-time = "2025-11-16T16:13:02.54Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/184173ac1e74fd35d308108bcbf83904d6ef8439c70763189225a166b238/ruamel_yaml_clib-0.2.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b6f7d74d094d1f3a4e157278da97752f16ee230080ae331fcc219056ca54f77", size = 132467, upload-time = "2025-11-16T16:13:03.539Z" }, + { url = "https://files.pythonhosted.org/packages/49/1b/2d2077a25fe682ae335007ca831aff42e3cbc93c14066675cf87a6c7fc3e/ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4be366220090d7c3424ac2b71c90d1044ea34fca8c0b88f250064fd06087e614", size = 693454, upload-time = "2025-11-16T20:22:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/90/16/e708059c4c429ad2e33be65507fc1730641e5f239fb2964efc1ba6edea94/ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f66f600833af58bea694d5892453f2270695b92200280ee8c625ec5a477eed3", size = 700345, upload-time = "2025-11-16T16:13:04.771Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/0e8ef51df1f0950300541222e3332f20707a9c210b98f981422937d1278c/ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da3d6adadcf55a93c214d23941aef4abfd45652110aed6580e814152f385b862", size = 731306, upload-time = "2025-11-16T16:13:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f4/2cdb54b142987ddfbd01fc45ac6bd882695fbcedb9d8bbf796adc3fc3746/ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9fde97ecb7bb9c41261c2ce0da10323e9227555c674989f8d9eb7572fc2098d", size = 692415, upload-time = "2025-11-16T16:13:07.465Z" }, + { url = "https://files.pythonhosted.org/packages/a0/07/40b5fc701cce8240a3e2d26488985d3bbdc446e9fe397c135528d412fea6/ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:05c70f7f86be6f7bee53794d80050a28ae7e13e4a0087c1839dcdefd68eb36b6", size = 705007, upload-time = "2025-11-16T20:22:42.856Z" }, + { url = "https://files.pythonhosted.org/packages/82/19/309258a1df6192fb4a77ffa8eae3e8150e8d0ffa56c1b6fa92e450ba2740/ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f1d38cbe622039d111b69e9ca945e7e3efebb30ba998867908773183357f3ed", size = 723974, upload-time = "2025-11-16T16:13:08.72Z" }, + { url = "https://files.pythonhosted.org/packages/67/3a/d6ee8263b521bfceb5cd2faeb904a15936480f2bb01c7ff74a14ec058ca4/ruamel_yaml_clib-0.2.15-cp310-cp310-win32.whl", hash = "sha256:fe239bdfdae2302e93bd6e8264bd9b71290218fff7084a9db250b55caaccf43f", size = 102836, upload-time = "2025-11-16T16:13:10.27Z" }, + { url = "https://files.pythonhosted.org/packages/ed/03/92aeb5c69018387abc49a8bb4f83b54a0471d9ef48e403b24bac68f01381/ruamel_yaml_clib-0.2.15-cp310-cp310-win_amd64.whl", hash = "sha256:468858e5cbde0198337e6a2a78eda8c3fb148bdf4c6498eaf4bc9ba3f8e780bd", size = 121917, upload-time = "2025-11-16T16:13:12.145Z" }, + { url = "https://files.pythonhosted.org/packages/2c/80/8ce7b9af532aa94dd83360f01ce4716264db73de6bc8efd22c32341f6658/ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c583229f336682b7212a43d2fa32c30e643d3076178fb9f7a6a14dde85a2d8bd", size = 147998, upload-time = "2025-11-16T16:13:13.241Z" }, + { url = "https://files.pythonhosted.org/packages/53/09/de9d3f6b6701ced5f276d082ad0f980edf08ca67114523d1b9264cd5e2e0/ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56ea19c157ed8c74b6be51b5fa1c3aff6e289a041575f0556f66e5fb848bb137", size = 132743, upload-time = "2025-11-16T16:13:14.265Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f7/73a9b517571e214fe5c246698ff3ed232f1ef863c8ae1667486625ec688a/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5fea0932358e18293407feb921d4f4457db837b67ec1837f87074667449f9401", size = 731459, upload-time = "2025-11-16T20:22:44.338Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a2/0dc0013169800f1c331a6f55b1282c1f4492a6d32660a0cf7b89e6684919/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71831bd61fbdb7aa0399d5c4da06bea37107ab5c79ff884cc07f2450910262", size = 749289, upload-time = "2025-11-16T16:13:15.633Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ed/3fb20a1a96b8dc645d88c4072df481fe06e0289e4d528ebbdcc044ebc8b3/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:617d35dc765715fa86f8c3ccdae1e4229055832c452d4ec20856136acc75053f", size = 777630, upload-time = "2025-11-16T16:13:16.898Z" }, + { url = "https://files.pythonhosted.org/packages/60/50/6842f4628bc98b7aa4733ab2378346e1441e150935ad3b9f3c3c429d9408/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b45498cc81a4724a2d42273d6cfc243c0547ad7c6b87b4f774cb7bcc131c98d", size = 744368, upload-time = "2025-11-16T16:13:18.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/128ae8e19a7d794c2e36130a72b3bb650ce1dd13fb7def6cf10656437dcf/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:def5663361f6771b18646620fca12968aae730132e104688766cf8a3b1d65922", size = 745233, upload-time = "2025-11-16T20:22:45.833Z" }, + { url = "https://files.pythonhosted.org/packages/75/05/91130633602d6ba7ce3e07f8fc865b40d2a09efd4751c740df89eed5caf9/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490", size = 770963, upload-time = "2025-11-16T16:13:19.344Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4b/fd4542e7f33d7d1bc64cc9ac9ba574ce8cf145569d21f5f20133336cdc8c/ruamel_yaml_clib-0.2.15-cp311-cp311-win32.whl", hash = "sha256:d290eda8f6ada19e1771b54e5706b8f9807e6bb08e873900d5ba114ced13e02c", size = 102640, upload-time = "2025-11-16T16:13:20.498Z" }, + { url = "https://files.pythonhosted.org/packages/bb/eb/00ff6032c19c7537371e3119287999570867a0eafb0154fccc80e74bf57a/ruamel_yaml_clib-0.2.15-cp311-cp311-win_amd64.whl", hash = "sha256:bdc06ad71173b915167702f55d0f3f027fc61abd975bd308a0968c02db4a4c3e", size = 121996, upload-time = "2025-11-16T16:13:21.855Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/5fde11a0722d676e469d3d6f78c6a17591b9c7e0072ca359801c4bd17eee/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff", size = 149088, upload-time = "2025-11-16T16:13:22.836Z" }, + { url = "https://files.pythonhosted.org/packages/85/82/4d08ac65ecf0ef3b046421985e66301a242804eb9a62c93ca3437dc94ee0/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2", size = 134553, upload-time = "2025-11-16T16:13:24.151Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cb/22366d68b280e281a932403b76da7a988108287adff2bfa5ce881200107a/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1", size = 737468, upload-time = "2025-11-16T20:22:47.335Z" }, + { url = "https://files.pythonhosted.org/packages/71/73/81230babf8c9e33770d43ed9056f603f6f5f9665aea4177a2c30ae48e3f3/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60", size = 753349, upload-time = "2025-11-16T16:13:26.269Z" }, + { url = "https://files.pythonhosted.org/packages/61/62/150c841f24cda9e30f588ef396ed83f64cfdc13b92d2f925bb96df337ba9/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9", size = 788211, upload-time = "2025-11-16T16:13:27.441Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/e79bd9cbecc3267499d9ead919bd61f7ddf55d793fb5ef2b1d7d92444f35/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642", size = 743203, upload-time = "2025-11-16T16:13:28.671Z" }, + { url = "https://files.pythonhosted.org/packages/8d/06/1eb640065c3a27ce92d76157f8efddb184bd484ed2639b712396a20d6dce/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690", size = 747292, upload-time = "2025-11-16T20:22:48.584Z" }, + { url = "https://files.pythonhosted.org/packages/a5/21/ee353e882350beab65fcc47a91b6bdc512cace4358ee327af2962892ff16/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a", size = 771624, upload-time = "2025-11-16T16:13:29.853Z" }, + { url = "https://files.pythonhosted.org/packages/57/34/cc1b94057aa867c963ecf9ea92ac59198ec2ee3a8d22a126af0b4d4be712/ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144", size = 100342, upload-time = "2025-11-16T16:13:31.067Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e5/8925a4208f131b218f9a7e459c0d6fcac8324ae35da269cb437894576366/ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc", size = 119013, upload-time = "2025-11-16T16:13:32.164Z" }, + { url = "https://files.pythonhosted.org/packages/17/5e/2f970ce4c573dc30c2f95825f2691c96d55560268ddc67603dc6ea2dd08e/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb", size = 147450, upload-time = "2025-11-16T16:13:33.542Z" }, + { url = "https://files.pythonhosted.org/packages/d6/03/a1baa5b94f71383913f21b96172fb3a2eb5576a4637729adbf7cd9f797f8/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471", size = 133139, upload-time = "2025-11-16T16:13:34.587Z" }, + { url = "https://files.pythonhosted.org/packages/dc/19/40d676802390f85784235a05788fd28940923382e3f8b943d25febbb98b7/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25", size = 731474, upload-time = "2025-11-16T20:22:49.934Z" }, + { url = "https://files.pythonhosted.org/packages/ce/bb/6ef5abfa43b48dd55c30d53e997f8f978722f02add61efba31380d73e42e/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a", size = 748047, upload-time = "2025-11-16T16:13:35.633Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5d/e4f84c9c448613e12bd62e90b23aa127ea4c46b697f3d760acc32cb94f25/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf", size = 782129, upload-time = "2025-11-16T16:13:36.781Z" }, + { url = "https://files.pythonhosted.org/packages/de/4b/e98086e88f76c00c88a6bcf15eae27a1454f661a9eb72b111e6bbb69024d/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d", size = 736848, upload-time = "2025-11-16T16:13:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5c/5964fcd1fd9acc53b7a3a5d9a05ea4f95ead9495d980003a557deb9769c7/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf", size = 741630, upload-time = "2025-11-16T20:22:51.718Z" }, + { url = "https://files.pythonhosted.org/packages/07/1e/99660f5a30fceb58494598e7d15df883a07292346ef5696f0c0ae5dee8c6/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51", size = 766619, upload-time = "2025-11-16T16:13:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/fa0344a9327b58b54970e56a27b32416ffbcfe4dcc0700605516708579b2/ruamel_yaml_clib-0.2.15-cp313-cp313-win32.whl", hash = "sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec", size = 100171, upload-time = "2025-11-16T16:13:40.456Z" }, + { url = "https://files.pythonhosted.org/packages/06/c4/c124fbcef0684fcf3c9b72374c2a8c35c94464d8694c50f37eef27f5a145/ruamel_yaml_clib-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6", size = 118845, upload-time = "2025-11-16T16:13:41.481Z" }, + { url = "https://files.pythonhosted.org/packages/3e/bd/ab8459c8bb759c14a146990bf07f632c1cbec0910d4853feeee4be2ab8bb/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef", size = 147248, upload-time = "2025-11-16T16:13:42.872Z" }, + { url = "https://files.pythonhosted.org/packages/69/f2/c4cec0a30f1955510fde498aac451d2e52b24afdbcb00204d3a951b772c3/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf", size = 133764, upload-time = "2025-11-16T16:13:43.932Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/2480d062281385a2ea4f7cc9476712446e0c548cd74090bff92b4b49e898/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000", size = 730537, upload-time = "2025-11-16T20:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/75/08/e365ee305367559f57ba6179d836ecc3d31c7d3fdff2a40ebf6c32823a1f/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4", size = 746944, upload-time = "2025-11-16T16:13:45.338Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/8b56b08db91e569d0a4fbfa3e492ed2026081bdd7e892f63ba1c88a2f548/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c", size = 778249, upload-time = "2025-11-16T16:13:46.871Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1d/70dbda370bd0e1a92942754c873bd28f513da6198127d1736fa98bb2a16f/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043", size = 737140, upload-time = "2025-11-16T16:13:48.349Z" }, + { url = "https://files.pythonhosted.org/packages/5b/87/822d95874216922e1120afb9d3fafa795a18fdd0c444f5c4c382f6dac761/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524", size = 741070, upload-time = "2025-11-16T20:22:54.151Z" }, + { url = "https://files.pythonhosted.org/packages/b9/17/4e01a602693b572149f92c983c1f25bd608df02c3f5cf50fd1f94e124a59/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e", size = 765882, upload-time = "2025-11-16T16:13:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/9f/17/7999399081d39ebb79e807314de6b611e1d1374458924eb2a489c01fc5ad/ruamel_yaml_clib-0.2.15-cp314-cp314-win32.whl", hash = "sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa", size = 102567, upload-time = "2025-11-16T16:13:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/67/be582a7370fdc9e6846c5be4888a530dcadd055eef5b932e0e85c33c7d73/ruamel_yaml_clib-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467", size = 122847, upload-time = "2025-11-16T16:13:51.807Z" }, +] + +[[package]] +name = "ruff" +version = "0.11.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" }, + { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" }, + { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" }, + { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" }, + { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" }, + { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "ty" +version = "0.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/25/257602d316b9333089b688a7a11b33ebc660b74e8dacf400dc3dfdea1594/ty-0.0.15.tar.gz", hash = "sha256:4f9a5b8df208c62dba56e91b93bed8b5bb714839691b8cff16d12c983bfa1174", size = 5101936, upload-time = "2026-02-05T01:06:34.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/c5/35626e732b79bf0e6213de9f79aff59b5f247c0a1e3ce0d93e675ab9b728/ty-0.0.15-py3-none-linux_armv6l.whl", hash = "sha256:68e092458516c61512dac541cde0a5e4e5842df00b4e81881ead8f745ddec794", size = 10138374, upload-time = "2026-02-05T01:07:03.804Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8a/48fd81664604848f79d03879b3ca3633762d457a069b07e09fb1b87edd6e/ty-0.0.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79f2e75289eae3cece94c51118b730211af4ba5762906f52a878041b67e54959", size = 9947858, upload-time = "2026-02-05T01:06:47.453Z" }, + { url = "https://files.pythonhosted.org/packages/b6/85/c1ac8e97bcd930946f4c94db85b675561d590b4e72703bf3733419fc3973/ty-0.0.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:112a7b26e63e48cc72c8c5b03227d1db280cfa57a45f2df0e264c3a016aa8c3c", size = 9443220, upload-time = "2026-02-05T01:06:44.98Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d9/244bc02599d950f7a4298fbc0c1b25cc808646b9577bdf7a83470b2d1cec/ty-0.0.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71f62a2644972975a657d9dc867bf901235cde51e8d24c20311067e7afd44a56", size = 9949976, upload-time = "2026-02-05T01:07:01.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ab/3a0daad66798c91a33867a3ececf17d314ac65d4ae2bbbd28cbfde94da63/ty-0.0.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e48b42be2d257317c85b78559233273b655dd636fc61e7e1d69abd90fd3cba4", size = 9965918, upload-time = "2026-02-05T01:06:54.283Z" }, + { url = "https://files.pythonhosted.org/packages/39/4e/e62b01338f653059a7c0cd09d1a326e9a9eedc351a0f0de9db0601658c3d/ty-0.0.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27dd5b52a421e6871c5bfe9841160331b60866ed2040250cb161886478ab3e4f", size = 10424943, upload-time = "2026-02-05T01:07:08.777Z" }, + { url = "https://files.pythonhosted.org/packages/65/b5/7aa06655ce69c0d4f3e845d2d85e79c12994b6d84c71699cfb437e0bc8cf/ty-0.0.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76b85c9ec2219e11c358a7db8e21b7e5c6674a1fb9b6f633836949de98d12286", size = 10964692, upload-time = "2026-02-05T01:06:37.103Z" }, + { url = "https://files.pythonhosted.org/packages/13/04/36fdfe1f3c908b471e246e37ce3d011175584c26d3853e6c5d9a0364564c/ty-0.0.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9e8204c61d8ede4f21f2975dce74efdb80fafb2fae1915c666cceb33ea3c90b", size = 10692225, upload-time = "2026-02-05T01:06:49.714Z" }, + { url = "https://files.pythonhosted.org/packages/13/41/5bf882649bd8b64ded5fbce7fb8d77fb3b868de1a3b1a6c4796402b47308/ty-0.0.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af87c3be7c944bb4d6609d6c63e4594944b0028c7bd490a525a82b88fe010d6d", size = 10516776, upload-time = "2026-02-05T01:06:52.047Z" }, + { url = "https://files.pythonhosted.org/packages/56/75/66852d7e004f859839c17ffe1d16513c1e7cc04bcc810edb80ca022a9124/ty-0.0.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:50dccf7398505e5966847d366c9e4c650b8c225411c2a68c32040a63b9521eea", size = 9928828, upload-time = "2026-02-05T01:06:56.647Z" }, + { url = "https://files.pythonhosted.org/packages/65/72/96bc16c7b337a3ef358fd227b3c8ef0c77405f3bfbbfb59ee5915f0d9d71/ty-0.0.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:bd797b8f231a4f4715110259ad1ad5340a87b802307f3e06d92bfb37b858a8f3", size = 9978960, upload-time = "2026-02-05T01:06:29.567Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/d2e316a35b626de2227f832cd36d21205e4f5d96fd036a8af84c72ecec1b/ty-0.0.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9deb7f20e18b25440a9aa4884f934ba5628ef456dbde91819d5af1a73da48af3", size = 10135903, upload-time = "2026-02-05T01:06:59.256Z" }, + { url = "https://files.pythonhosted.org/packages/02/d3/b617a79c9dad10c888d7c15cd78859e0160b8772273637b9c4241a049491/ty-0.0.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7b31b3de031255b90a5f4d9cb3d050feae246067c87130e5a6861a8061c71754", size = 10615879, upload-time = "2026-02-05T01:07:06.661Z" }, + { url = "https://files.pythonhosted.org/packages/fb/b0/2652a73c71c77296a6343217063f05745da60c67b7e8a8e25f2064167fce/ty-0.0.15-py3-none-win32.whl", hash = "sha256:9362c528ceb62c89d65c216336d28d500bc9f4c10418413f63ebc16886e16cc1", size = 9578058, upload-time = "2026-02-05T01:06:42.928Z" }, + { url = "https://files.pythonhosted.org/packages/84/6e/08a4aedebd2a6ce2784b5bc3760e43d1861f1a184734a78215c2d397c1df/ty-0.0.15-py3-none-win_amd64.whl", hash = "sha256:4db040695ae67c5524f59cb8179a8fa277112e69042d7dfdac862caa7e3b0d9c", size = 10457112, upload-time = "2026-02-05T01:06:39.885Z" }, + { url = "https://files.pythonhosted.org/packages/b3/be/1991f2bc12847ae2d4f1e3ac5dcff8bb7bc1261390645c0755bb55616355/ty-0.0.15-py3-none-win_arm64.whl", hash = "sha256:e5a98d4119e77d6136461e16ae505f8f8069002874ab073de03fbcb1a5e8bf25", size = 9937490, upload-time = "2026-02-05T01:06:32.388Z" }, +] + +[[package]] +name = "typeguard" +version = "4.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1c/dfba5c4633cafc4c701f237d2ba63b416805047fd6d96aab4cfc40969f98/typeguard-4.5.2.tar.gz", hash = "sha256:5a16dcac23502039299c97c8941651bc33d7ea8cc4b2f7d6bbb1b528f6eea423", size = 80240, upload-time = "2026-05-14T12:59:40.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl", hash = "sha256:fcf9de18bd945cdb4c7b996e12b4c51ce83f92f191314a6d7cf1739586ec98cf", size = 36748, upload-time = "2026-05-14T12:59:39.473Z" }, +] + +[[package]] +name = "typer" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/78/d90f616bf5f88f8710ad067c1f8705bf7618059836ca084e5bb2a0855d75/typer-0.16.1.tar.gz", hash = "sha256:d358c65a464a7a90f338e3bb7ff0c74ac081449e53884b12ba658cbd72990614", size = 102836, upload-time = "2025-08-18T19:18:22.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/76/06dbe78f39b2203d2a47d5facc5df5102d0561e2807396471b5f7c5a30a1/typer-0.16.1-py3-none-any.whl", hash = "sha256:90ee01cb02d9b8395ae21ee3368421faf21fa138cb2a541ed369c08cec5237c9", size = 46397, upload-time = "2025-08-18T19:18:21.663Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "wcmatch" +version = "10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/98/eb989c3113908e2ef46d940a53695a1ebb4be5a732c4a4f700be8f8d682b/wcmatch-10.2.tar.gz", hash = "sha256:92204839e3e9c945e1e71d7e1e4edeab2601ed50a5c51ff4f3f97ca711eeb738", size = 132499, upload-time = "2026-06-30T00:50:07.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/73/aef4aaf16b8d785762e2b14cf321a9178cc84a1bf4c40f58320f499a2d65/wcmatch-10.2-py3-none-any.whl", hash = "sha256:f1a79e80ccbe296907b7eaf57d8d3bc49eab0b428d35f7d09986b5079b6e4a5d", size = 39742, upload-time = "2026-06-30T00:50:05.927Z" }, +] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..2f47833 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,6940 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: true + +overrides: + '@next/eslint-plugin-next>glob@*': 10.5.0 + rollup@>=4: '>=4.59.0' + postcss@<8.5.10: ^8.5.10 + vite@>=6.0.0 <6.4.2: ^6.4.2 + lodash@<4.18.0: ^4.18.0 + brace-expansion@>=2.0.0 <2.0.3: ^2.0.3 + picomatch@<2.3.2: ^2.3.2 + picomatch@>=4.0.0 <4.0.4: ^4.0.4 + yaml@>=2.0.0 <2.8.3: ^2.8.3 + '@tootallnate/once@<3.0.1': ^3.0.1 + smol-toml@<1.6.1: ^1.6.1 + flatted@<3.4.2: ^3.4.2 + minimatch@<3.1.3: ^3.1.3 + minimatch@>=5.0.0 <5.1.8: ^5.1.8 + minimatch@>=9.0.0 <9.0.7: ^9.0.7 + minimatch@>=10.0.0 <10.2.3: ^10.2.3 + ws@>=8.0.0 <8.20.1: ^8.20.1 + shell-quote@<1.8.4: ^1.8.4 + +importers: + + .: + dependencies: + '@changesets/read': + specifier: ^0.6.2 + version: 0.6.2 + devDependencies: + changeset: + specifier: ^0.2.6 + version: 0.2.6 + oxlint: + specifier: ^1.72.0 + version: 1.72.0 + + packages/cli: + dependencies: + '@iarna/toml': + specifier: ^2.2.5 + version: 2.2.5 + '@inquirer/prompts': + specifier: ^7.9.0 + version: 7.9.0(@types/node@20.19.43) + '@npmcli/package-json': + specifier: ^5.2.1 + version: 5.2.1 + async-listen: + specifier: ^3.0.1 + version: 3.0.1 + boxen: + specifier: ^7.1.1 + version: 7.1.1 + chalk: + specifier: ^5.3.0 + version: 5.3.0 + cli-highlight: + specifier: ^2.1.11 + version: 2.1.11 + commander: + specifier: ^11.1.0 + version: 11.1.0 + console-table-printer: + specifier: ^2.11.2 + version: 2.11.2 + e2b: + specifier: ^2.32.0 + version: 2.32.0 + handlebars: + specifier: ^4.7.9 + version: 4.7.9 + inquirer: + specifier: ^12.10.0 + version: 12.10.0(@types/node@20.19.43) + simple-update-notifier: + specifier: ^2.0.0 + version: 2.0.0 + statuses: + specifier: ^2.0.1 + version: 2.0.1 + yup: + specifier: ^1.3.2 + version: 1.3.2 + devDependencies: + '@types/handlebars': + specifier: ^4.1.0 + version: 4.1.0 + '@types/inquirer': + specifier: ^9.0.7 + version: 9.0.7 + '@types/json2md': + specifier: ^1.5.4 + version: 1.5.4 + '@types/node': + specifier: ^20.19.19 + version: 20.19.43 + '@types/npmcli__package-json': + specifier: ^4.0.4 + version: 4.0.4 + '@types/statuses': + specifier: ^2.0.5 + version: 2.0.5 + '@typescript/native': + specifier: npm:typescript@^7.0.2 + version: typescript@7.0.2 + '@vitest/coverage-v8': + specifier: ^4.1.0 + version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8) + json2md: + specifier: ^2.0.1 + version: 2.0.1 + knip: + specifier: ^5.43.6 + version: 5.43.6(@types/node@20.19.43)(@typescript/typescript6@6.0.2) + tsdown: + specifier: ^0.22.3 + version: 0.22.4(@typescript/typescript6@6.0.2) + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' + vitest: + specifier: ^4.1.8 + version: 4.1.8(@types/node@20.19.43)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0)) + + packages/js-sdk: + dependencies: + '@bufbuild/protobuf': + specifier: ^2.12.1 + version: 2.12.1 + '@connectrpc/connect': + specifier: ^2.1.2 + version: 2.1.2(@bufbuild/protobuf@2.12.1) + '@connectrpc/connect-web': + specifier: ^2.1.2 + version: 2.1.2(@bufbuild/protobuf@2.12.1)(@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.12.1)) + chalk: + specifier: ^5.3.0 + version: 5.3.0 + compare-versions: + specifier: ^6.1.0 + version: 6.1.1 + dockerfile-ast: + specifier: ^0.7.1 + version: 0.7.1 + glob: + specifier: ^11.1.0 + version: 11.1.0 + openapi-fetch: + specifier: ^0.14.1 + version: 0.14.1 + platform: + specifier: ^1.3.6 + version: 1.3.6 + tar: + specifier: ^7.5.16 + version: 7.5.16 + undici: + specifier: ^7.28.0 + version: 7.28.0 + devDependencies: + '@testing-library/react': + specifier: ^16.2.0 + version: 16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/node': + specifier: ^20.19.19 + version: 20.19.43 + '@types/platform': + specifier: ^1.3.6 + version: 1.3.6 + '@types/react': + specifier: ^19.2.0 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.0 + version: 19.2.3(@types/react@19.2.17) + '@typescript/native': + specifier: npm:typescript@^7.0.2 + version: typescript@7.0.2 + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.3.4(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0)) + '@vitest/browser': + specifier: ^4.1.0 + version: 4.1.8(bufferutil@4.0.8)(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(utf-8-validate@6.0.3)(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0))(vitest@4.1.8) + '@vitest/browser-playwright': + specifier: ^4.1.0 + version: 4.1.8(bufferutil@4.0.8)(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(playwright@1.55.1)(utf-8-validate@6.0.3)(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0))(vitest@4.1.8) + dotenv: + specifier: ^16.4.5 + version: 16.4.5 + json-schema-to-typescript: + specifier: ^15.0.4 + version: 15.0.4 + knip: + specifier: ^5.43.6 + version: 5.43.6(@types/node@20.19.43)(@typescript/typescript6@6.0.2) + msw: + specifier: ^2.12.10 + version: 2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2) + npm-run-all: + specifier: ^4.1.5 + version: 4.1.5 + openapi-typescript: + specifier: ^7.9.1 + version: 7.9.1(@typescript/typescript6@6.0.2) + playwright: + specifier: ^1.55.1 + version: 1.55.1 + react: + specifier: ^19.2.0 + version: 19.2.7 + react-dom: + specifier: ^19.2.0 + version: 19.2.7(react@19.2.7) + tsdown: + specifier: ^0.22.3 + version: 0.22.4(@typescript/typescript6@6.0.2) + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' + vitest: + specifier: ^4.1.8 + version: 4.1.8(@types/node@20.19.43)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0)) + vitest-browser-react: + specifier: ^2.2.0 + version: 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8) + + packages/python-sdk: {} + + spec: + devDependencies: + prettier: + specifier: ^3.3.3 + version: 3.6.2 + +packages: + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@apidevtools/json-schema-ref-parser@11.9.3': + resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} + engines: {node: '>= 16'} + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.27.2': + resolution: {integrity: sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.27.1': + resolution: {integrity: sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.27.1': + resolution: {integrity: sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.27.1': + resolution: {integrity: sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.26.5': + resolution: {integrity: sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.25.7': + resolution: {integrity: sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.25.7': + resolution: {integrity: sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.27.1': + resolution: {integrity: sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.25.8': + resolution: {integrity: sha512-HcttkxzdPucv3nNFmfOOMfFf64KgdJVqm1KaCm25dPGMLElo9nsLvXeJECQg8UzPuBGLyTSA0ZzqCtDSzKTEoQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@7.27.2': + resolution: {integrity: sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.25.9': + resolution: {integrity: sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.25.9': + resolution: {integrity: sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.27.1': + resolution: {integrity: sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.27.1': + resolution: {integrity: sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.25.8': + resolution: {integrity: sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.27.1': + resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + + '@bufbuild/protobuf@2.12.1': + resolution: {integrity: sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==} + + '@bufbuild/protobuf@2.6.2': + resolution: {integrity: sha512-vLu7SRY84CV/Dd+NUdgtidn2hS5hSMUC1vDBY0VcviTdgRYkU43vIz3vIFbmx14cX1r+mM7WjzE5Fl1fGEM0RQ==} + + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + + '@changesets/git@3.0.2': + resolution: {integrity: sha512-r1/Kju9Y8OxRRdvna+nxpQIsMsRQn9dhhAZt94FLDeu0Hij2hnOozW8iqnHBgvu+KdnJppCveQwK4odwfw/aWQ==} + + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + + '@changesets/parse@0.4.0': + resolution: {integrity: sha512-TS/9KG2CdGXS27S+QxbZXgr8uPsP4yNJYb4BC2/NeFUj80Rni3TeD2qwWmabymxmrLo7JEsytXH1FbpKTbvivw==} + + '@changesets/read@0.6.2': + resolution: {integrity: sha512-wjfQpJvryY3zD61p8jR87mJdyx2FIhEcdXhKUqkja87toMrP/3jtg/Yg29upN+N4Ckf525/uvV7a4tzBlpk6gg==} + + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + + '@changesets/types@6.0.0': + resolution: {integrity: sha512-b1UkfNulgKoWfqyHtzKS5fOZYSJO+77adgL7DLRDr+/7jhChN+QcHnbjiQVOz/U+Ts3PGNySq7diAItzDgugfQ==} + + '@connectrpc/connect-web@2.0.0-rc.3': + resolution: {integrity: sha512-w88P8Lsn5CCsA7MFRl2e6oLY4J/5toiNtJns/YJrlyQaWOy3RO8pDgkz+iIkG98RPMhj2thuBvsd3Cn4DKKCkw==} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + '@connectrpc/connect': 2.0.0-rc.3 + + '@connectrpc/connect-web@2.1.2': + resolution: {integrity: sha512-1tfaK85MU+gJjwwmL31d2rzdf0XCYX99chZf63uG89SGBUd4XuZ4ZzhGo2u79TPXOE6nLIZQ2okrpyey42PYdg==} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + '@connectrpc/connect': 2.1.2 + + '@connectrpc/connect@2.0.0-rc.3': + resolution: {integrity: sha512-ARBt64yEyKbanyRETTjcjJuHr2YXorzQo0etyS5+P6oSeW8xEuzajA9g+zDnMcj1hlX2dQE93foIWQGfpru7gQ==} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + + '@connectrpc/connect@2.1.2': + resolution: {integrity: sha512-MXkBijtcX09R10Eb6sFeIetc6w6746eio6xtfuyVOH7oQAacT1X0GzMIQFux6Qy8cq3W/T5qX5Bei8YbFtmRGA==} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@iarna/toml@2.2.5': + resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + + '@inquirer/ansi@1.0.1': + resolution: {integrity: sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.0': + resolution: {integrity: sha512-5+Q3PKH35YsnoPTh75LucALdAxom6xh5D1oeY561x4cqBuH24ZFVyFREPe14xgnrtmGu3EEt1dIi60wRVSnGCw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.19': + resolution: {integrity: sha512-wQNz9cfcxrtEnUyG5PndC8g3gZ7lGDBzmWiXZkX8ot3vfZ+/BLjR8EvyGX4YzQLeVqtAlY/YScZpW7CW8qMoDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.0': + resolution: {integrity: sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.21': + resolution: {integrity: sha512-MjtjOGjr0Kh4BciaFShYpZ1s9400idOdvQ5D7u7lE6VztPFoyLcVNE5dXBmEEIQq5zi4B9h2kU+q7AVBxJMAkQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.21': + resolution: {integrity: sha512-+mScLhIcbPFmuvU3tAGBed78XvYHSvCl6dBiYMlzCLhpr0bzGzd8tfivMMeqND6XZiaZ1tgusbUHJEfc6YzOdA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.2': + resolution: {integrity: sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.14': + resolution: {integrity: sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==} + engines: {node: '>=18'} + + '@inquirer/input@4.2.5': + resolution: {integrity: sha512-7GoWev7P6s7t0oJbenH0eQ0ThNdDJbEAEtVt9vsrYZ9FulIokvd823yLyhQlWHJPGce1wzP53ttfdCZmonMHyA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.21': + resolution: {integrity: sha512-5QWs0KGaNMlhbdhOSCFfKsW+/dcAVC2g4wT/z2MCiZM47uLgatC5N20kpkDQf7dHx+XFct/MJvvNGy6aYJn4Pw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.21': + resolution: {integrity: sha512-xxeW1V5SbNFNig2pLfetsDb0svWlKuhmr7MPJZMYuDnCTkpVBI+X/doudg4pznc1/U+yYmWFFOi4hNvGgUo7EA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.9.0': + resolution: {integrity: sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.9': + resolution: {integrity: sha512-AWpxB7MuJrRiSfTKGJ7Y68imYt8P9N3Gaa7ySdkFj1iWjr6WfbGAhdZvw/UnhFXTHITJzxGUI9k8IX7akAEBCg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.0': + resolution: {integrity: sha512-a5SzB/qrXafDX1Z4AZW3CsVoiNxcIYCzYP7r9RzrfMpaLpB+yWi5U8BWagZyLmwR0pKbbL5umnGRd0RzGVI8bQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.0': + resolution: {integrity: sha512-kaC3FHsJZvVyIjYBs5Ih8y8Bj4P/QItQWrZW22WJax7zTN+ZPXVGuOM55vzbdCP9zKUiBd9iEJVdesujfF+cAA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.9': + resolution: {integrity: sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.1': + resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@jridgewell/trace-mapping@0.3.30': + resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} + + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + + '@mswjs/interceptors@0.41.3': + resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} + engines: {node: '>=18'} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.scandir@4.0.1': + resolution: {integrity: sha512-vAkI715yhnmiPupY+dq+xenu5Tdf2TBQ66jLvBIcCddtz+5Q8LbMKaf9CIJJreez8fQ8fgaY+RaywQx8RJIWpw==} + engines: {node: '>=18.18.0'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@4.0.0': + resolution: {integrity: sha512-ctr6bByzksKRCV0bavi8WoQevU6plSp2IkllIsEqaiKe2mwNNnaluhnRhcsgGZHrrHk57B3lf95MkLMO3STYcg==} + engines: {node: '>=18.18.0'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@3.0.1': + resolution: {integrity: sha512-nIh/M6Kh3ZtOmlY00DaUYB4xeeV6F3/ts1l29iwl3/cfyY/OuCfUx+v08zgx8TKPTifXRcjjqVQ4KB2zOYSbyw==} + engines: {node: '>=18.18.0'} + + '@npmcli/git@5.0.8': + resolution: {integrity: sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@npmcli/package-json@5.2.1': + resolution: {integrity: sha512-f7zYC6kQautXHvNbLEWgD/uGu1+xCn9izgqBfgItWSx22U0ZDekxN08A1vM8cTxj/cRVe0Q94Ode+tdoYmIOOQ==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@npmcli/promise-spawn@7.0.2': + resolution: {integrity: sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@oxlint/binding-android-arm-eabi@1.72.0': + resolution: {integrity: sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.72.0': + resolution: {integrity: sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.72.0': + resolution: {integrity: sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.72.0': + resolution: {integrity: sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.72.0': + resolution: {integrity: sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + resolution: {integrity: sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + resolution: {integrity: sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.72.0': + resolution: {integrity: sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.72.0': + resolution: {integrity: sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + resolution: {integrity: sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + resolution: {integrity: sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.72.0': + resolution: {integrity: sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.72.0': + resolution: {integrity: sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.72.0': + resolution: {integrity: sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.72.0': + resolution: {integrity: sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.72.0': + resolution: {integrity: sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.72.0': + resolution: {integrity: sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.72.0': + resolution: {integrity: sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.72.0': + resolution: {integrity: sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@polka/url@1.0.0-next.28': + resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.2': + resolution: {integrity: sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==} + + '@redocly/openapi-core@1.34.5': + resolution: {integrity: sha512-0EbE8LRbkogtcCXU7liAyC00n9uNG9hJ+eMyHFdUsy9lB/WGqnEBgwjA9q2cyzAVcdTkQqTBBU1XePNnN3OijA==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.62.0': + resolution: {integrity: sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.0': + resolution: {integrity: sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.0': + resolution: {integrity: sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.0': + resolution: {integrity: sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.0': + resolution: {integrity: sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.0': + resolution: {integrity: sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.0': + resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.0': + resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.0': + resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.0': + resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.0': + resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.0': + resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.0': + resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.0': + resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.0': + resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.0': + resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.0': + resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.0': + resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.0': + resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.0': + resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.0': + resolution: {integrity: sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.0': + resolution: {integrity: sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.0': + resolution: {integrity: sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.0': + resolution: {integrity: sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.0': + resolution: {integrity: sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==} + cpu: [x64] + os: [win32] + + '@snyk/github-codeowners@1.1.0': + resolution: {integrity: sha512-lGFf08pbkEac0NYgVf4hdANpAgApRjNByLXB+WBip3qj1iendOIyAwP2GKkKbQMNVy2r1xxDf0ssfWscoiC+Vw==} + engines: {node: '>=8.10'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + hasBin: true + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@testing-library/dom@10.4.0': + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + engines: {node: '>=18'} + + '@testing-library/react@16.2.0': + resolution: {integrity: sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.6.8': + resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.20.6': + resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/handlebars@4.1.0': + resolution: {integrity: sha512-gq9YweFKNNB1uFK71eRqsd4niVkXrxHugqWFQkeLRJvGjnxsLr16bYtcsG4tOFwmYi0Bax+wCkbf1reUfdl4kA==} + deprecated: This is a stub types definition. handlebars provides its own type definitions, so you do not need this installed. + + '@types/inquirer@9.0.7': + resolution: {integrity: sha512-Q0zyBupO6NxGRZut/JdmqYKOnN95Eg5V8Csg3PGKkP+FnvsUZx1jAyK7fztIszxxMuoBA6E3KXWvdZVXIpx60g==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json2md@1.5.4': + resolution: {integrity: sha512-OFTAYD7Nnyu7FZPGnDwYbGOTKqzDfX71uFSgTbGhcr0aHCi17QkSOY3wO5H4yv5h23Ly+7suvf2lzHcdeDIH2Q==} + + '@types/lodash@4.17.20': + resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@types/npmcli__package-json@4.0.4': + resolution: {integrity: sha512-6QjlFUSHBmZJWuC08bz1ZCx6tm4t+7+OJXAdvM6tL2pI7n6Bh5SIp/YxQvnOLFf8MzCXs2ijyFgrzaiu1UFBGA==} + + '@types/platform@1.3.6': + resolution: {integrity: sha512-ZmSaqHuvzv+jC232cFoz2QqPUkaj6EvMmCrWcx3WRr7xTPVFCMUOTcOq8m2d+Zw1iKRc1kDiaA+jtNrV0hkVew==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/statuses@2.0.5': + resolution: {integrity: sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + + '@types/through@0.0.33': + resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@typescript/typescript6@6.0.2': + resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==} + hasBin: true + + '@vitejs/plugin-react@4.3.4': + resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^6.4.2 + + '@vitest/browser-playwright@4.1.8': + resolution: {integrity: sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==} + peerDependencies: + playwright: '*' + vitest: 4.1.8 + + '@vitest/browser@4.1.8': + resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==} + peerDependencies: + vitest: 4.1.8 + + '@vitest/coverage-v8@4.1.8': + resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} + peerDependencies: + '@vitest/browser': 4.1.8 + vitest: 4.1.8 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.4.2 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + + '@yuku-codegen/binding-darwin-arm64@0.5.46': + resolution: {integrity: sha512-Kb3ULHGCN3lCfFj8L6YzSeIg1rcpXRuzbDc47KYBdK9R/8YW9HWKWfHji+WF/91QiCDb7u0VMhHgB/dPjpT2qg==} + cpu: [arm64] + os: [darwin] + + '@yuku-codegen/binding-darwin-x64@0.5.46': + resolution: {integrity: sha512-5yJZFGJOU2ijYcD1gr7ooIlzqOTArE0YPNWEC2LlLGjqcYUpC3wPU+FVtDO1xLW60oJQlG2T54pbYsuXCsTdMA==} + cpu: [x64] + os: [darwin] + + '@yuku-codegen/binding-freebsd-x64@0.5.46': + resolution: {integrity: sha512-5K6x+Ll4qcfoU1lfDvkpK7i3r8a+bJYsE3zj8SnRoAHSu21au53E4c3XE1u8KlHo8JIiMsT6W8ZyXM3QKdSymQ==} + cpu: [x64] + os: [freebsd] + + '@yuku-codegen/binding-linux-arm-gnu@0.5.46': + resolution: {integrity: sha512-biSavMngiyj1kk0BPvAAHm2y6Xw87cXzjfZtPHA0zETz0CwZLz8NLxe3K8ZroEJb1jZUCMhV6SnMR86gB5s8bA==} + cpu: [arm] + os: [linux] + + '@yuku-codegen/binding-linux-arm-musl@0.5.46': + resolution: {integrity: sha512-1Tuduchx+rt3xeWnZtLB430UYjP6mY2xhG3lw72q1YrFl9fHEvwzKaA9Uuiq3BkMhz5x5urIpfLV8XAf20Nqgw==} + cpu: [arm] + os: [linux] + + '@yuku-codegen/binding-linux-arm64-gnu@0.5.46': + resolution: {integrity: sha512-++sIi/yitZHeB3Xav8P88Vh+E53Ej/959FUfPdTElV4FNAwdRsEtxoNw37mASy2gkiwOhEjIIy986FXaKrYTdg==} + cpu: [arm64] + os: [linux] + + '@yuku-codegen/binding-linux-arm64-musl@0.5.46': + resolution: {integrity: sha512-/UMkxyjjXMX5yq6mk4Z1GdSDYWv3CMNfN2Dv7SdKwKpl6Lwp6aR+RPIiiC+oRAI5PaFrrorJIg3BloU3ss/sLQ==} + cpu: [arm64] + os: [linux] + + '@yuku-codegen/binding-linux-x64-gnu@0.5.46': + resolution: {integrity: sha512-/qtlyhxsXWGy1777IJ1RScNp2p+znbR/WJ9ZK5dZRQh0851pdJCPQALuLhEAFdQjfnT8sJPJgB61RPS9Ou6uzQ==} + cpu: [x64] + os: [linux] + + '@yuku-codegen/binding-linux-x64-musl@0.5.46': + resolution: {integrity: sha512-9PtrZqSNvsz7UZl5Nbol8TFB6VXMKAVbBdF/CPU/96qt3kFAeNTNO7O4SZERiPr4dDYzf8i9IxmDSoMywJyQHQ==} + cpu: [x64] + os: [linux] + + '@yuku-codegen/binding-win32-arm64@0.5.46': + resolution: {integrity: sha512-VwW0f6z8NwpvI7DORWL9I5m2p51VF/Cz7FlemTEwMQT4DUWIvGEgqVLChty1+CVgdLros+OIIVmcgWbP1u/u2Q==} + cpu: [arm64] + os: [win32] + + '@yuku-codegen/binding-win32-x64@0.5.46': + resolution: {integrity: sha512-4l+eRLvivimYCpgODeyvaVutu7LsBxP9NexAPrTXEG+ttXNF6YcOL+ApYuC6/Jap2ojdm82t8d1wD4ExszLVrA==} + cpu: [x64] + os: [win32] + + '@yuku-parser/binding-darwin-arm64@0.5.46': + resolution: {integrity: sha512-k8ljigcfWnr+qEgJLBuayjXdTeXdAqS6yo0jITnDKE7zIoq73lmdAKvD5+g8pHXnfoGPzNA7+TH4z/MHwFHD4g==} + cpu: [arm64] + os: [darwin] + + '@yuku-parser/binding-darwin-x64@0.5.46': + resolution: {integrity: sha512-yGeFK/ac5iWtwQyXIJbyKIFS2kODKZCVUSd/uVxFYT8OiA5KvG1jgq+Ca44ezBG2WrEyc3/IM+4sLwPRkJHp3w==} + cpu: [x64] + os: [darwin] + + '@yuku-parser/binding-freebsd-x64@0.5.46': + resolution: {integrity: sha512-rZ8GTjrfavwS8QzYjv6OuXGWcuHJpyGCRSzDk1nfGr0d8dmdQx6htbTl/vUY8BgkiZftsiZnNeDulKjf4m0M8A==} + cpu: [x64] + os: [freebsd] + + '@yuku-parser/binding-linux-arm-gnu@0.5.46': + resolution: {integrity: sha512-gttOTy0Swirw40+tA5vnmraJ2xqLrY0Hn0VjPT8AIfMwUgKjefT7pvRxI8HTeLCCYHgAaNiud1LQWnvPMgUXiA==} + cpu: [arm] + os: [linux] + + '@yuku-parser/binding-linux-arm-musl@0.5.46': + resolution: {integrity: sha512-GBH7ASymEaazgC4Zo4LODpgEV4Sn3l8qQq6hTC9B7ftvfZGy8CJx0qtj0zNWH3SpOvjWjTDVVxUjRXVnHRpKDQ==} + cpu: [arm] + os: [linux] + + '@yuku-parser/binding-linux-arm64-gnu@0.5.46': + resolution: {integrity: sha512-TWwBKtCjny12ef5tAScFYcIyTWG69WAx7I/vTyhQto2yydmotNUwOuViUsmwVex+Ldix05n1Z7naZF/5xTlWzg==} + cpu: [arm64] + os: [linux] + + '@yuku-parser/binding-linux-arm64-musl@0.5.46': + resolution: {integrity: sha512-7VfxvUQKepU6bwG5FX6XLU3cXvw50wePuW+f8cDYOfuTEWGCrirI8NlK6afy+udofAEmjhH82GsrXupOactIfw==} + cpu: [arm64] + os: [linux] + + '@yuku-parser/binding-linux-x64-gnu@0.5.46': + resolution: {integrity: sha512-r2ytt3LNpAyBP/s9kvLtPgldkyCXJGZFpteoZK40NaRo55Cfd5KFJRxdekK3mgBnm2gnQnx9qWaOniXUolj/HQ==} + cpu: [x64] + os: [linux] + + '@yuku-parser/binding-linux-x64-musl@0.5.46': + resolution: {integrity: sha512-nsE+ajANECHyR09xCVHlXtbNiexSUutzrSb1fhU3JVEfVKIMfsP1LDIWh9w43FG2GRODv3lByuSl1AwnuBj0jA==} + cpu: [x64] + os: [linux] + + '@yuku-parser/binding-win32-arm64@0.5.46': + resolution: {integrity: sha512-/jkn8U4s649vZXxDD+UmfqSB5OM8H9wjPg545q0V0d3Lj0K4CwaUOqvStaBrmxI21NM3WU/mM6xzslFHlJ3fVA==} + cpu: [arm64] + os: [win32] + + '@yuku-parser/binding-win32-x64@0.5.46': + resolution: {integrity: sha512-pZlngLHCrQT1lcQXTW/F+Vld6akOyRiaDjj8TAK9ongNRAEZawencz4X01kLOeWwJWx88Xvz7nT0iBwdY6ZkHA==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.5.43': + resolution: {integrity: sha512-kSpvPntnXw5+lYjO71ffBEnQ5ycQ74KGIYknh0TS4xeyCuBkOqxyJumxZkMhLBBUCLjDAbx2+Icnr3Zh4ftjpQ==} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.3: + resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} + engines: {node: '>= 14'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.0.1: + resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} + engines: {node: '>=12'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.3: + resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==} + + async-listen@3.0.1: + resolution: {integrity: sha512-cWMaNwUJnf37C/S5TfCkk/15MwbPRwVYALA2jtjkbHjCmAPiDXyNJy2q3p1KAZzDLHAWyarUWSujUoHR4pEgrA==} + engines: {node: '>= 14'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} + + boxen@7.1.1: + resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} + engines: {node: '>=14.16'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.24.4: + resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bufferutil@4.0.8: + resolution: {integrity: sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==} + engines: {node: '>=6.14.2'} + + builtins@5.0.1: + resolution: {integrity: sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase@7.0.1: + resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} + engines: {node: '>=14.16'} + + caniuse-lite@1.0.30001753: + resolution: {integrity: sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + changeset@0.2.6: + resolution: {integrity: sha512-d21ym9zLPOKMVhIa8ulJo5IV3QR2NNdK6BWuwg48qJA0XSQaMeDjo1UGThcTn7YDmU08j3UpKyFNvb3zplk8mw==} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-highlight@2.1.11: + resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + console-table-printer@2.11.2: + resolution: {integrity: sha512-uuUHie0sfPP542TKGzPFal0W1wo1beuKAqIZdaavcONx8OoqdnJRKjkinbRTOta4FaCa1RcIL+7mMJWX3pQGVg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@6.0.6: + resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} + engines: {node: '>=4.8'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.0: + resolution: {integrity: sha512-UzGwzcjyv3OtAvolTj1GoyNYzfFR+iqbGjcnBEENZVCpM4/Ng1yhGNvS3lR/xDS74Tb2wGG9WzNSNIOS9UVb2g==} + engines: {node: '>= 0.4'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dockerfile-ast@0.7.1: + resolution: {integrity: sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + e2b@2.32.0: + resolution: {integrity: sha512-gdim5paDburEw92qrxZwxmQK/xFH7BRE49XTPxJDYzXyonSPygvf2dgnm+nurZhglLiyhV/e7XAYr5x6d+61dA==} + engines: {node: '>=20.18.1'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + easy-table@1.2.0: + resolution: {integrity: sha512-OFzVOv03YpvtcWGe5AayU5G2hgybsg3iqA6drU8UaoZyB9jLGMTrz9+asnLp/E+6qPh88yEI1gvyZFZ41dmgww==} + + electron-to-chromium@1.5.80: + resolution: {integrity: sha512-LTrKpW0AqIuHwmlVNV+cjFYTnXtM9K37OGhpe0ZI10ScPSxqVSryZHIY3WnCS5NSYbBODRTZyhRMS2h5FAEqAw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} + engines: {node: '>=14'} + + enhanced-resolve@5.18.1: + resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} + engines: {node: '>=10.13.0'} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + + es-abstract@1.24.0: + resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.1.1: + resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + engines: {node: '>=6'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} + engines: {node: '>=8.6.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.15.0: + resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^4.0.4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + for-each@0.3.3: + resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.2.1: + resolution: {integrity: sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@5.0.0-beta.5: + resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} + engines: {node: '>=20.20.0'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphql@16.12.0: + resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-bigints@1.0.2: + resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.0: + resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.0.1: + resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==} + engines: {node: '>= 0.4'} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.0: + resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + has@1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + + hookable@6.1.1: + resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + + hosted-git-info@2.8.9: + resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.7.0: + resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + engines: {node: '>=0.10.0'} + + ignore@5.2.4: + resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} + engines: {node: '>= 4'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-without-cache@0.4.0: + resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} + engines: {node: ^22.18.0 || >=24.0.0} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + indento@1.1.13: + resolution: {integrity: sha512-YZWk3mreBEM7sBPddsiQnW9Z8SGg/gNpFfscJq00HCDS7pxcQWWWMSVKJU7YkTRyDu1Zv2s8zaK8gQWKmCXHlg==} + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + + ini@4.1.3: + resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + inquirer@12.10.0: + resolution: {integrity: sha512-K/epfEnDBZj2Q3NMDcgXWZye3nhSPeoJnOh8lcKWrldw54UEZfS4EmAMsAsmVbl7qKi+vjAsy39Sz4fbgRMewg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-async-function@2.0.0: + resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.13.0: + resolution: {integrity: sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.0.5: + resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.0.10: + resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} + + is-symbol@1.0.4: + resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.1: + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} + engines: {node: '>=16'} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jackspeak@4.1.1: + resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} + engines: {node: 20 || >=22} + + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.0.2: + resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} + engines: {node: '>=6'} + hasBin: true + + json-parse-better-errors@1.0.2: + resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} + + json-parse-even-better-errors@3.0.0: + resolution: {integrity: sha512-iZbGHafX/59r39gPwVPRBGw0QQKnA7tte5pSMrhWOW7swGsVvVTjmfyAV9pNqk8YGT7tRCdxRu8uzcgZwoDooA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + json-schema-to-typescript@15.0.4: + resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==} + engines: {node: '>=16.0.0'} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json2md@2.0.1: + resolution: {integrity: sha512-VbwmZ83qmUfKBS2pUOHlzNKEZFPBeJSbzEok3trMYyboZUgdHNn1XZfc1uT8UZs1GHCrmRUBXCfqw4YmmQuOhw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + knip@5.43.6: + resolution: {integrity: sha512-bUCFlg44imdV5vayYxu0pIAB373S8Ufjda0qaI9oRZDH6ltJFwUoAO2j7nafxDmo5G0ZeP4IiLAHqlc3wYIONQ==} + engines: {node: '>=18.18.0'} + hasBin: true + peerDependencies: + '@types/node': '>=18' + typescript: '>=5.0.4' + + load-json-file@4.0.0: + resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} + engines: {node: '>=4'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.1.0: + resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + memorystream@0.3.1: + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} + engines: {node: '>= 0.10.0'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mrmime@2.0.0: + resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msw@2.12.14: + resolution: {integrity: sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.13: + resolution: {integrity: sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + nice-try@1.0.5: + resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + + normalize-package-data@2.5.0: + resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} + + normalize-package-data@6.0.2: + resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} + engines: {node: ^16.14.0 || >=18.0.0} + + npm-install-checks@6.3.0: + resolution: {integrity: sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + npm-normalize-package-bin@3.0.1: + resolution: {integrity: sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + npm-package-arg@11.0.3: + resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} + engines: {node: ^16.14.0 || >=18.0.0} + + npm-pick-manifest@9.1.0: + resolution: {integrity: sha512-nkc+3pIIhqHVQr085X9d2JzPzLyjzQS96zbruppqC9aZRm/x8xx6xhI98gHtsfELP2bE+loHq8ZaHFHhe+NauA==} + engines: {node: ^16.14.0 || >=18.0.0} + + npm-run-all@4.1.5: + resolution: {integrity: sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==} + engines: {node: '>= 4'} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + openapi-fetch@0.14.1: + resolution: {integrity: sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==} + + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + + openapi-typescript@7.9.1: + resolution: {integrity: sha512-9gJtoY04mk6iPMbToPjPxEAtfXZ0dTsMZtsgUI8YZta0btPPig9DJFP4jlerQD/7QOwYgb0tl+zLUpDf7vb7VA==} + hasBin: true + peerDependencies: + typescript: ^5.x + + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + oxlint@1.72.0: + resolution: {integrity: sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.0: + resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} + + parse-json@4.0.0: + resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} + engines: {node: '>=4'} + + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + + parse5@5.1.1: + resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@2.0.1: + resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} + engines: {node: '>=4'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + path-type@3.0.0: + resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==} + engines: {node: '>=4'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pidtree@0.3.1: + resolution: {integrity: sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==} + engines: {node: '>=0.10'} + hasBin: true + + pify@3.0.0: + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} + engines: {node: '>=4'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + + playwright-core@1.55.1: + resolution: {integrity: sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.55.1: + resolution: {integrity: sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==} + engines: {node: '>=18'} + hasBin: true + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + pretty-ms@9.1.0: + resolution: {integrity: sha512-o1piW0n3tgKIKCwk2vpM/vOV13zjJzvP37Ioze54YlTHE06m4tjEbzg9WsKkvTuyYln2DHjo5pY4qrZGI0otpw==} + engines: {node: '>=18'} + + proc-log@4.2.0: + resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + promise-inflight@1.0.1: + resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} + peerDependencies: + bluebird: '*' + peerDependenciesMeta: + bluebird: + optional: true + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + property-expr@2.0.6: + resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + read-pkg@3.0.0: + resolution: {integrity: sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==} + engines: {node: '>=4'} + + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + rettime@0.10.1: + resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rolldown-plugin-dts@0.27.4: + resolution: {integrity: sha512-z1uz1gH2sJ55i6JY/xi3q7gsI5CPthefDp5HYXnBlrkN1L3K4tx+gyAQO3Udt69ASWItWKwXJ55nKQq2HCMVfA==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@ts-macro/tsc': ^0.3.6 + '@typescript/native-preview': '>=7.0.0-dev.20260325.1' + rolldown: ^1.0.0 + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@ts-macro/tsc': + optional: true + '@typescript/native-preview': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.62.0: + resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-async@4.0.6: + resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==} + engines: {node: '>=0.12.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + shebang-command@1.2.0: + resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} + engines: {node: '>=0.10.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@1.0.0: + resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} + engines: {node: '>=0.10.0'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + simple-wcswidth@1.0.1: + resolution: {integrity: sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.3.0: + resolution: {integrity: sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.15: + resolution: {integrity: sha512-lpT8hSQp9jAKp9mhtBU4Xjon8LPGBvLIuBiSVhMEtmLecTh2mO0tlqrAMp47tBXzMr13NJMQ2lf7RpQGLJ3HsQ==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string.prototype.padend@3.1.6: + resolution: {integrity: sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==} + engines: {node: '>= 0.4'} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@5.0.1: + resolution: {integrity: sha512-0fk9zBqO67Nq5M/m45qHCJxylV/DhBlIOVExqgOMiCCrzrhU6tCibRXNqE3jwJLftzE9SNuZtYbpzcO+i9FiKw==} + engines: {node: '>=14.16'} + + summary@2.1.0: + resolution: {integrity: sha512-nMIjMrd5Z2nuB2RZCKJfFMjgS3fygbeyGk9PxPPaJR1RIcyN9yn4A63Isovzm3ZtQuEkLBVgMdPup8UeLH7aQw==} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + + tar@7.5.16: + resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} + engines: {node: '>=18'} + + terser@5.46.0: + resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==} + engines: {node: '>=10'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tiny-case@1.0.3: + resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + hasBin: true + + to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tsdown@0.22.4: + resolution: {integrity: sha512-3a5FsNL2fH2jw3ozvFUuPMBgS0xXjX9wpZShHyB4klXelVhyaNw5Q5WA9TPCNeoGYpRZEc4OZdMx5wT4Fkma3A==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.4 + '@tsdown/exe': 0.22.4 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + type-fest@5.2.0: + resolution: {integrity: sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA==} + engines: {node: '>=20'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + udc@1.0.1: + resolution: {integrity: sha512-jv+D9de1flsum5QkFtBdjyppCQAdz9kTck/0xST5Vx48T9LL2BYnw0Iw77dSKDQ9KZ/PS3qPO1vfXHDpLZlxcQ==} + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + underscore@1.13.6: + resolution: {integrity: sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + + update-browserslist-db@1.1.2: + resolution: {integrity: sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + + utf-8-validate@6.0.3: + resolution: {integrity: sha512-uIuGf9TWQ/y+0Lp+KGZCMuJWc3N9BHA+l/UmHd/oUHwJJDeysyTRxNQVkbzsIWfGFbRe3OcgML/i0mvVRPOyDA==} + engines: {node: '>=6.14.2'} + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + + validate-npm-package-name@5.0.0: + resolution: {integrity: sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.8.3 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest-browser-react@2.2.0: + resolution: {integrity: sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + vitest: ^4.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.2 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + widest-line@4.0.1: + resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} + engines: {node: '>=12'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yuku-ast@0.1.7: + resolution: {integrity: sha512-2RiMEWv500TixY5rJy6OZd4fSy9WYZKWh6gGbIJ7y7vAGcuCugWOWwOLGaQcRZrXcPUfqtLtvpaJ3SdXtWlhKA==} + + yuku-codegen@0.5.46: + resolution: {integrity: sha512-2qFouFH7ag332HJhqLd3/QGqGQtSIjnTU1/5Y4BTvRWMwR252rezKBtEBHq/s+rwLc7uKoLvbm2BzQzzsJghTQ==} + + yuku-parser@0.5.46: + resolution: {integrity: sha512-eMNzX5eYnkqo6zNYf2H8WHcMPHfIf7ijmw0X8NYZ1ANXAU5Y9rwTB9MgfCuvLxlR7fV/96v3gWa8y/YUGFLxjw==} + + yup@1.3.2: + resolution: {integrity: sha512-6KCM971iQtJ+/KUaHdrhVr2LDkfhBtFPRnsG1P8F4q3uUVQ2RfEM9xekpha9aA4GXWJevjM10eDcPQ1FfWlmaQ==} + + zod-validation-error@3.4.0: + resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.18.0 + + zod@3.22.4: + resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + +snapshots: + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 + + '@apidevtools/json-schema-ref-parser@11.9.3': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.1.1 + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.27.1 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.27.2': {} + + '@babel/core@7.27.1': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.27.1 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.27.1(@babel/core@7.27.1) + '@babel/helpers': 7.27.1 + '@babel/parser': 7.27.2 + '@babel/template': 7.27.2 + '@babel/traverse': 7.27.1 + '@babel/types': 7.27.1 + convert-source-map: 2.0.0 + debug: 4.4.0 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.27.1': + dependencies: + '@babel/parser': 7.27.2 + '@babel/types': 7.27.1 + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.0.2 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.27.2 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.24.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.27.1 + '@babel/types': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + '@babel/traverse': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.26.5': {} + + '@babel/helper-string-parser@7.25.7': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.25.7': {} + + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.27.1': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.27.1 + + '@babel/parser@7.25.8': + dependencies: + '@babel/types': 7.27.1 + + '@babel/parser@7.27.2': + dependencies: + '@babel/types': 7.27.1 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-transform-react-jsx-source@7.25.9(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/runtime@7.27.1': {} + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.27.2 + '@babel/types': 7.27.1 + + '@babel/traverse@7.27.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.27.1 + '@babel/parser': 7.27.2 + '@babel/template': 7.27.2 + '@babel/types': 7.27.1 + debug: 4.4.3(supports-color@10.2.2) + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.25.8': + dependencies: + '@babel/helper-string-parser': 7.25.7 + '@babel/helper-validator-identifier': 7.25.7 + to-fast-properties: 2.0.0 + + '@babel/types@7.27.1': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@blazediff/core@1.9.1': {} + + '@bufbuild/protobuf@2.12.1': {} + + '@bufbuild/protobuf@2.6.2': {} + + '@changesets/errors@0.2.0': + dependencies: + extendable-error: 0.1.7 + + '@changesets/git@3.0.2': + dependencies: + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 + + '@changesets/logger@0.1.1': + dependencies: + picocolors: 1.1.1 + + '@changesets/parse@0.4.0': + dependencies: + '@changesets/types': 6.0.0 + js-yaml: 3.14.2 + + '@changesets/read@0.6.2': + dependencies: + '@changesets/git': 3.0.2 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.0 + '@changesets/types': 6.0.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 + + '@changesets/types@4.1.0': {} + + '@changesets/types@6.0.0': {} + + '@connectrpc/connect-web@2.0.0-rc.3(@bufbuild/protobuf@2.6.2)(@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.6.2))': + dependencies: + '@bufbuild/protobuf': 2.6.2 + '@connectrpc/connect': 2.0.0-rc.3(@bufbuild/protobuf@2.6.2) + + '@connectrpc/connect-web@2.1.2(@bufbuild/protobuf@2.12.1)(@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.12.1))': + dependencies: + '@bufbuild/protobuf': 2.12.1 + '@connectrpc/connect': 2.1.2(@bufbuild/protobuf@2.12.1) + + '@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.6.2)': + dependencies: + '@bufbuild/protobuf': 2.6.2 + + '@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.12.1)': + dependencies: + '@bufbuild/protobuf': 2.12.1 + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@iarna/toml@2.2.5': {} + + '@inquirer/ansi@1.0.1': {} + + '@inquirer/checkbox@4.3.0(@types/node@20.19.43)': + dependencies: + '@inquirer/ansi': 1.0.1 + '@inquirer/core': 10.3.0(@types/node@20.19.43) + '@inquirer/figures': 1.0.14 + '@inquirer/type': 3.0.9(@types/node@20.19.43) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/confirm@5.1.19(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@20.19.43) + '@inquirer/type': 3.0.9(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/core@10.3.0(@types/node@20.19.43)': + dependencies: + '@inquirer/ansi': 1.0.1 + '@inquirer/figures': 1.0.14 + '@inquirer/type': 3.0.9(@types/node@20.19.43) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/editor@4.2.21(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@20.19.43) + '@inquirer/external-editor': 1.0.2(@types/node@20.19.43) + '@inquirer/type': 3.0.9(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/expand@4.0.21(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@20.19.43) + '@inquirer/type': 3.0.9(@types/node@20.19.43) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/external-editor@1.0.2(@types/node@20.19.43)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.0 + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/figures@1.0.14': {} + + '@inquirer/input@4.2.5(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@20.19.43) + '@inquirer/type': 3.0.9(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/number@3.0.21(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@20.19.43) + '@inquirer/type': 3.0.9(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/password@4.0.21(@types/node@20.19.43)': + dependencies: + '@inquirer/ansi': 1.0.1 + '@inquirer/core': 10.3.0(@types/node@20.19.43) + '@inquirer/type': 3.0.9(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/prompts@7.9.0(@types/node@20.19.43)': + dependencies: + '@inquirer/checkbox': 4.3.0(@types/node@20.19.43) + '@inquirer/confirm': 5.1.19(@types/node@20.19.43) + '@inquirer/editor': 4.2.21(@types/node@20.19.43) + '@inquirer/expand': 4.0.21(@types/node@20.19.43) + '@inquirer/input': 4.2.5(@types/node@20.19.43) + '@inquirer/number': 3.0.21(@types/node@20.19.43) + '@inquirer/password': 4.0.21(@types/node@20.19.43) + '@inquirer/rawlist': 4.1.9(@types/node@20.19.43) + '@inquirer/search': 3.2.0(@types/node@20.19.43) + '@inquirer/select': 4.4.0(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/rawlist@4.1.9(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@20.19.43) + '@inquirer/type': 3.0.9(@types/node@20.19.43) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/search@3.2.0(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@20.19.43) + '@inquirer/figures': 1.0.14 + '@inquirer/type': 3.0.9(@types/node@20.19.43) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/select@4.4.0(@types/node@20.19.43)': + dependencies: + '@inquirer/ansi': 1.0.1 + '@inquirer/core': 10.3.0(@types/node@20.19.43) + '@inquirer/figures': 1.0.14 + '@inquirer/type': 3.0.9(@types/node@20.19.43) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/type@3.0.9(@types/node@20.19.43)': + optionalDependencies: + '@types/node': 20.19.43 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/gen-mapping@0.3.8': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.1': {} + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.1 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jridgewell/trace-mapping@0.3.30': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jsdevtools/ono@7.1.3': {} + + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.27.1 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.27.1 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + + '@mswjs/interceptors@0.41.3': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.scandir@4.0.1': + dependencies: + '@nodelib/fs.stat': 4.0.0 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.stat@4.0.0': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.15.0 + + '@nodelib/fs.walk@3.0.1': + dependencies: + '@nodelib/fs.scandir': 4.0.1 + fastq: 1.15.0 + + '@npmcli/git@5.0.8': + dependencies: + '@npmcli/promise-spawn': 7.0.2 + ini: 4.1.3 + lru-cache: 10.4.3 + npm-pick-manifest: 9.1.0 + proc-log: 4.2.0 + promise-inflight: 1.0.1 + promise-retry: 2.0.1 + semver: 7.7.2 + which: 4.0.0 + transitivePeerDependencies: + - bluebird + + '@npmcli/package-json@5.2.1': + dependencies: + '@npmcli/git': 5.0.8 + glob: 10.5.0 + hosted-git-info: 7.0.2 + json-parse-even-better-errors: 3.0.0 + normalize-package-data: 6.0.2 + proc-log: 4.2.0 + semver: 7.7.2 + transitivePeerDependencies: + - bluebird + + '@npmcli/promise-spawn@7.0.2': + dependencies: + which: 4.0.0 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@oxc-project/types@0.139.0': {} + + '@oxlint/binding-android-arm-eabi@1.72.0': + optional: true + + '@oxlint/binding-android-arm64@1.72.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.72.0': + optional: true + + '@oxlint/binding-darwin-x64@1.72.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.72.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.72.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.72.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.72.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.72.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.72.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.72.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.72.0': + optional: true + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@polka/url@1.0.0-next.28': {} + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.2': {} + + '@redocly/openapi-core@1.34.5(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.2 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.1.1 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.62.0': + optional: true + + '@rollup/rollup-android-arm64@4.62.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.0': + optional: true + + '@rollup/rollup-darwin-x64@4.62.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.0': + optional: true + + '@snyk/github-codeowners@1.1.0': + dependencies: + commander: 4.1.1 + ignore: 5.3.2 + p-map: 4.0.0 + + '@standard-schema/spec@1.1.0': {} + + '@testing-library/dom@10.4.0': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + + '@testing-library/react@16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.27.1 + '@testing-library/dom': 10.4.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.25.8 + '@babel/types': 7.25.8 + '@types/babel__generator': 7.6.8 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.6 + + '@types/babel__generator@7.6.8': + dependencies: + '@babel/types': 7.27.1 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.27.2 + '@babel/types': 7.27.1 + + '@types/babel__traverse@7.20.6': + dependencies: + '@babel/types': 7.27.1 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/handlebars@4.1.0': + dependencies: + handlebars: 4.7.9 + + '@types/inquirer@9.0.7': + dependencies: + '@types/through': 0.0.33 + rxjs: 7.8.1 + + '@types/json-schema@7.0.15': {} + + '@types/json2md@1.5.4': {} + + '@types/lodash@4.17.20': {} + + '@types/node@12.20.55': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@types/npmcli__package-json@4.0.4': {} + + '@types/platform@1.3.6': {} + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/statuses@2.0.5': {} + + '@types/statuses@2.0.6': {} + + '@types/through@0.0.33': + dependencies: + '@types/node': 20.19.43 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@typescript/typescript6@6.0.2': + dependencies: + '@typescript/old': typescript@6.0.3 + + '@vitejs/plugin-react@4.3.4(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0))': + dependencies: + '@babel/core': 7.27.1 + '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.27.1) + '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.27.1) + '@types/babel__core': 7.20.5 + react-refresh: 0.14.2 + vite: 6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0) + transitivePeerDependencies: + - supports-color + + '@vitest/browser-playwright@4.1.8(bufferutil@4.0.8)(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(playwright@1.55.1)(utf-8-validate@6.0.3)(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0))(vitest@4.1.8)': + dependencies: + '@vitest/browser': 4.1.8(bufferutil@4.0.8)(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(utf-8-validate@6.0.3)(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0))(vitest@4.1.8) + '@vitest/mocker': 4.1.8(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0)) + playwright: 1.55.1 + tinyrainbow: 3.1.0 + vitest: 4.1.8(@types/node@20.19.43)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.8(bufferutil@4.0.8)(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(utf-8-validate@6.0.3)(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0))(vitest@4.1.8)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.8(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0)) + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.8(@types/node@20.19.43)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0)) + ws: 8.21.0(bufferutil@4.0.8)(utf-8-validate@6.0.3) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.8 + ast-v8-to-istanbul: 1.0.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.8(@types/node@20.19.43)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0)) + optionalDependencies: + '@vitest/browser': 4.1.8(bufferutil@4.0.8)(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(utf-8-validate@6.0.3)(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0))(vitest@4.1.8) + + '@vitest/expect@4.1.8': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.8(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2) + vite: 6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0) + + '@vitest/pretty-format@4.1.8': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.8': + dependencies: + '@vitest/utils': 4.1.8 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.8': {} + + '@vitest/utils@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@yuku-codegen/binding-darwin-arm64@0.5.46': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.5.46': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.5.46': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.5.46': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.5.46': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.5.46': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.5.46': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.5.46': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.5.46': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.5.46': + optional: true + + '@yuku-codegen/binding-win32-x64@0.5.46': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.5.46': + optional: true + + '@yuku-parser/binding-darwin-x64@0.5.46': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.5.46': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.5.46': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.5.46': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.5.46': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.5.46': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.5.46': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.5.46': + optional: true + + '@yuku-parser/binding-win32-arm64@0.5.46': + optional: true + + '@yuku-parser/binding-win32-x64@0.5.46': + optional: true + + '@yuku-toolchain/types@0.5.43': {} + + acorn@8.17.0: + optional: true + + agent-base@7.1.3: {} + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.0.1: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.1: {} + + ansis@4.3.1: {} + + any-promise@1.3.0: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-union@2.1.0: {} + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.3: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + async-listen@3.0.1: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + better-path-resolve@1.0.0: + dependencies: + is-windows: 1.0.2 + + boxen@7.1.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.3.0 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.24.4: + dependencies: + caniuse-lite: 1.0.30001753 + electron-to-chromium: 1.5.80 + node-releases: 2.0.19 + update-browserslist-db: 1.1.2(browserslist@4.24.4) + + buffer-from@1.1.2: + optional: true + + bufferutil@4.0.8: + dependencies: + node-gyp-build: 4.8.4 + optional: true + + builtins@5.0.1: + dependencies: + semver: 7.7.2 + + cac@7.0.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase@7.0.1: {} + + caniuse-lite@1.0.30001753: {} + + chai@6.2.2: {} + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.3.0: {} + + change-case@5.4.4: {} + + changeset@0.2.6: + dependencies: + udc: 1.0.1 + underscore: 1.13.6 + + chardet@2.1.1: {} + + chownr@3.0.0: {} + + clean-stack@2.2.0: {} + + cli-boxes@3.0.0: {} + + cli-highlight@2.1.11: + dependencies: + chalk: 4.1.2 + highlight.js: 10.7.3 + mz: 2.7.0 + parse5: 5.1.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + yargs: 16.2.0 + + cli-width@4.1.0: {} + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: + optional: true + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + colorette@1.4.0: {} + + commander@11.1.0: {} + + commander@2.20.3: + optional: true + + commander@4.1.1: {} + + compare-versions@6.1.1: {} + + concat-map@0.0.1: {} + + console-table-printer@2.11.2: + dependencies: + simple-wcswidth: 1.0.1 + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cross-spawn@6.0.6: + dependencies: + nice-try: 1.0.5 + path-key: 2.0.1 + semver: 5.7.2 + shebang-command: 1.2.0 + which: 1.3.1 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@4.4.0: + dependencies: + ms: 2.1.3 + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + optional: true + + define-data-property@1.1.0: + dependencies: + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.0 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.0 + has-property-descriptors: 1.0.0 + object-keys: 1.1.1 + + defu@6.1.7: {} + + dequal@2.0.3: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dockerfile-ast@0.7.1: + dependencies: + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + + dom-accessibility-api@0.5.16: {} + + dotenv@16.4.5: {} + + dts-resolver@3.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + e2b@2.32.0: + dependencies: + '@bufbuild/protobuf': 2.6.2 + '@connectrpc/connect': 2.0.0-rc.3(@bufbuild/protobuf@2.6.2) + '@connectrpc/connect-web': 2.0.0-rc.3(@bufbuild/protobuf@2.6.2)(@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.6.2)) + chalk: 5.3.0 + compare-versions: 6.1.1 + dockerfile-ast: 0.7.1 + glob: 11.1.0 + openapi-fetch: 0.14.1 + platform: 1.3.6 + tar: 7.5.16 + undici: 7.28.0 + + eastasianwidth@0.2.0: {} + + easy-table@1.2.0: + dependencies: + ansi-regex: 5.0.1 + optionalDependencies: + wcwidth: 1.0.1 + + electron-to-chromium@1.5.80: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + empathic@2.0.1: {} + + enhanced-resolve@5.18.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + + err-code@2.0.3: {} + + error-ex@1.3.2: + dependencies: + is-arrayish: 0.2.1 + + es-abstract@1.24.0: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.19 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.1.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.0.5 + is-symbol: 1.0.4 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.1.1: {} + + escalade@3.2.0: {} + + escape-string-regexp@1.0.5: {} + + esprima@4.0.1: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + extendable-error@0.1.7: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.1: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.15.0: + dependencies: + reusify: 1.0.4 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + for-each@0.3.3: + dependencies: + is-callable: 1.2.7 + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.2.1: + dependencies: + function-bind: 1.1.2 + has: 1.0.3 + has-proto: 1.0.1 + has-symbols: 1.0.3 + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@5.0.0-beta.5: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.2 + package-json-from-dist: 1.0.0 + path-scurry: 1.11.1 + + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.1.1 + minimatch: 10.2.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.0 + path-scurry: 2.0.0 + + globals@11.12.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.1 + ignore: 5.2.4 + merge2: 1.4.1 + slash: 3.0.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphql@16.12.0: {} + + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-bigints@1.0.2: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.0: + dependencies: + get-intrinsic: 1.2.1 + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.0.1: {} + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.0.3: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.0: + dependencies: + has-symbols: 1.0.3 + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.0.3 + + has@1.0.3: + dependencies: + function-bind: 1.1.2 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + headers-polyfill@4.0.3: {} + + highlight.js@10.7.3: {} + + hookable@6.1.1: {} + + hosted-git-info@2.8.9: {} + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + html-escaper@2.0.2: {} + + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.3 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + iconv-lite@0.7.0: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.2.4: {} + + ignore@5.3.2: {} + + import-without-cache@0.4.0: {} + + indent-string@4.0.0: {} + + indento@1.1.13: {} + + index-to-position@1.2.0: {} + + ini@4.1.3: {} + + inquirer@12.10.0(@types/node@20.19.43): + dependencies: + '@inquirer/ansi': 1.0.1 + '@inquirer/core': 10.3.0(@types/node@20.19.43) + '@inquirer/prompts': 7.9.0(@types/node@20.19.43) + '@inquirer/type': 3.0.9(@types/node@20.19.43) + mute-stream: 2.0.0 + run-async: 4.0.6 + rxjs: 7.8.2 + optionalDependencies: + '@types/node': 20.19.43 + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-async-function@2.0.0: + dependencies: + has-tostringtag: 1.0.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.0.2 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.13.0: + dependencies: + has: 1.0.3 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.0.5: + dependencies: + has-tostringtag: 1.0.0 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-generator-function@1.0.10: + dependencies: + has-tostringtag: 1.0.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-node-process@1.2.0: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-subdir@1.2.0: + dependencies: + better-path-resolve: 1.0.0 + + is-symbol@1.0.4: + dependencies: + has-symbols: 1.0.3 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.19 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-windows@1.0.2: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isexe@3.1.1: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jackspeak@4.1.1: + dependencies: + '@isaacs/cliui': 8.0.2 + + jiti@2.4.2: {} + + js-levenshtein@1.1.6: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.0.2: {} + + json-parse-better-errors@1.0.2: {} + + json-parse-even-better-errors@3.0.0: {} + + json-schema-to-typescript@15.0.4: + dependencies: + '@apidevtools/json-schema-ref-parser': 11.9.3 + '@types/json-schema': 7.0.15 + '@types/lodash': 4.17.20 + is-glob: 4.0.3 + js-yaml: 4.1.1 + lodash: 4.18.1 + minimist: 1.2.8 + prettier: 3.6.2 + tinyglobby: 0.2.15 + + json-schema-traverse@1.0.0: {} + + json2md@2.0.1: + dependencies: + indento: 1.1.13 + + json5@2.2.3: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + knip@5.43.6(@types/node@20.19.43)(@typescript/typescript6@6.0.2): + dependencies: + '@nodelib/fs.walk': 3.0.1 + '@snyk/github-codeowners': 1.1.0 + '@types/node': 20.19.43 + easy-table: 1.2.0 + enhanced-resolve: 5.18.1 + fast-glob: 3.3.3 + jiti: 2.4.2 + js-yaml: 4.1.1 + minimist: 1.2.8 + picocolors: 1.1.1 + picomatch: 4.0.4 + pretty-ms: 9.1.0 + smol-toml: 1.6.1 + strip-json-comments: 5.0.1 + summary: 2.1.0 + typescript: '@typescript/typescript6@6.0.2' + zod: 3.22.4 + zod-validation-error: 3.4.0(zod@3.22.4) + + load-json-file@4.0.0: + dependencies: + graceful-fs: 4.2.11 + parse-json: 4.0.0 + pify: 3.0.0 + strip-bom: 3.0.0 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lodash@4.18.1: {} + + lru-cache@10.4.3: {} + + lru-cache@11.1.0: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + + math-intrinsics@1.1.0: {} + + memorystream@0.3.1: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + + minimist@1.2.8: {} + + minipass@7.1.2: {} + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mrmime@2.0.0: {} + + ms@2.1.3: {} + + msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2): + dependencies: + '@inquirer/confirm': 5.1.19(@types/node@20.19.43) + '@mswjs/interceptors': 0.41.3 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.12.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.2.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' + transitivePeerDependencies: + - '@types/node' + + mute-stream@2.0.0: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.13: {} + + neo-async@2.6.2: {} + + nice-try@1.0.5: {} + + node-gyp-build@4.8.4: + optional: true + + node-releases@2.0.19: {} + + normalize-package-data@2.5.0: + dependencies: + hosted-git-info: 2.8.9 + resolve: 1.22.8 + semver: 5.7.2 + validate-npm-package-license: 3.0.4 + + normalize-package-data@6.0.2: + dependencies: + hosted-git-info: 7.0.2 + semver: 7.7.2 + validate-npm-package-license: 3.0.4 + + npm-install-checks@6.3.0: + dependencies: + semver: 7.7.2 + + npm-normalize-package-bin@3.0.1: {} + + npm-package-arg@11.0.3: + dependencies: + hosted-git-info: 7.0.2 + proc-log: 4.2.0 + semver: 7.7.2 + validate-npm-package-name: 5.0.0 + + npm-pick-manifest@9.1.0: + dependencies: + npm-install-checks: 6.3.0 + npm-normalize-package-bin: 3.0.1 + npm-package-arg: 11.0.3 + semver: 7.7.2 + + npm-run-all@4.1.5: + dependencies: + ansi-styles: 3.2.1 + chalk: 2.4.2 + cross-spawn: 6.0.6 + memorystream: 0.3.1 + minimatch: 3.1.5 + pidtree: 0.3.1 + read-pkg: 3.0.0 + shell-quote: 1.8.4 + string.prototype.padend: 3.1.6 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + obug@2.1.1: {} + + obug@2.1.3: {} + + openapi-fetch@0.14.1: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} + + openapi-typescript@7.9.1(@typescript/typescript6@6.0.2): + dependencies: + '@redocly/openapi-core': 1.34.5(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: '@typescript/typescript6@6.0.2' + yargs-parser: 21.1.1 + + outvariant@1.4.3: {} + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + oxlint@1.72.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.72.0 + '@oxlint/binding-android-arm64': 1.72.0 + '@oxlint/binding-darwin-arm64': 1.72.0 + '@oxlint/binding-darwin-x64': 1.72.0 + '@oxlint/binding-freebsd-x64': 1.72.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.72.0 + '@oxlint/binding-linux-arm-musleabihf': 1.72.0 + '@oxlint/binding-linux-arm64-gnu': 1.72.0 + '@oxlint/binding-linux-arm64-musl': 1.72.0 + '@oxlint/binding-linux-ppc64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-musl': 1.72.0 + '@oxlint/binding-linux-s390x-gnu': 1.72.0 + '@oxlint/binding-linux-x64-gnu': 1.72.0 + '@oxlint/binding-linux-x64-musl': 1.72.0 + '@oxlint/binding-openharmony-arm64': 1.72.0 + '@oxlint/binding-win32-arm64-msvc': 1.72.0 + '@oxlint/binding-win32-ia32-msvc': 1.72.0 + '@oxlint/binding-win32-x64-msvc': 1.72.0 + + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-map@2.1.0: {} + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.0: {} + + parse-json@4.0.0: + dependencies: + error-ex: 1.3.2 + json-parse-better-errors: 1.0.2 + + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.27.1 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + parse-ms@4.0.0: {} + + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5@5.1.1: {} + + parse5@6.0.1: {} + + path-exists@4.0.0: {} + + path-key@2.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-scurry@2.0.0: + dependencies: + lru-cache: 11.1.0 + minipass: 7.1.2 + + path-to-regexp@6.3.0: {} + + path-type@3.0.0: + dependencies: + pify: 3.0.0 + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + picomatch@4.0.5: {} + + pidtree@0.3.1: {} + + pify@3.0.0: {} + + pify@4.0.1: {} + + platform@1.3.6: {} + + playwright-core@1.55.1: {} + + playwright@1.55.1: + dependencies: + playwright-core: 1.55.1 + optionalDependencies: + fsevents: 2.3.2 + + pluralize@8.0.0: {} + + pngjs@7.0.0: {} + + possible-typed-array-names@1.1.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.13 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.6.2: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + pretty-ms@9.1.0: + dependencies: + parse-ms: 4.0.0 + + proc-log@4.2.0: {} + + promise-inflight@1.0.1: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + + property-expr@2.0.6: {} + + quansync@1.0.0: {} + + queue-microtask@1.2.3: {} + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react-refresh@0.14.2: {} + + react@19.2.7: {} + + read-pkg@3.0.0: + dependencies: + load-json-file: 4.0.0 + normalize-package-data: 2.5.0 + path-type: 3.0.0 + + read-yaml-file@1.1.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.14.2 + pify: 4.0.1 + strip-bom: 3.0.0 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.8: + dependencies: + is-core-module: 2.13.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + retry@0.12.0: {} + + rettime@0.10.1: {} + + reusify@1.0.4: {} + + rolldown-plugin-dts@0.27.4(@typescript/typescript6@6.0.2)(rolldown@1.1.5): + dependencies: + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.5 + obug: 2.1.3 + rolldown: 1.1.5 + yuku-ast: 0.1.7 + yuku-codegen: 0.5.46 + yuku-parser: 0.5.46 + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + rollup@4.62.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.0 + '@rollup/rollup-android-arm64': 4.62.0 + '@rollup/rollup-darwin-arm64': 4.62.0 + '@rollup/rollup-darwin-x64': 4.62.0 + '@rollup/rollup-freebsd-arm64': 4.62.0 + '@rollup/rollup-freebsd-x64': 4.62.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.0 + '@rollup/rollup-linux-arm-musleabihf': 4.62.0 + '@rollup/rollup-linux-arm64-gnu': 4.62.0 + '@rollup/rollup-linux-arm64-musl': 4.62.0 + '@rollup/rollup-linux-loong64-gnu': 4.62.0 + '@rollup/rollup-linux-loong64-musl': 4.62.0 + '@rollup/rollup-linux-ppc64-gnu': 4.62.0 + '@rollup/rollup-linux-ppc64-musl': 4.62.0 + '@rollup/rollup-linux-riscv64-gnu': 4.62.0 + '@rollup/rollup-linux-riscv64-musl': 4.62.0 + '@rollup/rollup-linux-s390x-gnu': 4.62.0 + '@rollup/rollup-linux-x64-gnu': 4.62.0 + '@rollup/rollup-linux-x64-musl': 4.62.0 + '@rollup/rollup-openbsd-x64': 4.62.0 + '@rollup/rollup-openharmony-arm64': 4.62.0 + '@rollup/rollup-win32-arm64-msvc': 4.62.0 + '@rollup/rollup-win32-ia32-msvc': 4.62.0 + '@rollup/rollup-win32-x64-gnu': 4.62.0 + '@rollup/rollup-win32-x64-msvc': 4.62.0 + fsevents: 2.3.3 + + run-async@4.0.6: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@5.7.2: {} + + semver@6.3.1: {} + + semver@7.7.2: {} + + semver@7.8.5: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + shebang-command@1.2.0: + dependencies: + shebang-regex: 1.0.0 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@1.0.0: {} + + shebang-regex@3.0.0: {} + + shell-quote@1.8.4: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.2 + + simple-wcswidth@1.0.1: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.28 + mrmime: 2.0.0 + totalist: 3.0.1 + + slash@3.0.0: {} + + smol-toml@1.6.1: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + optional: true + + source-map@0.6.1: {} + + spawndamnit@3.0.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.15 + + spdx-exceptions@2.3.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.3.0 + spdx-license-ids: 3.0.15 + + spdx-license-ids@3.0.15: {} + + sprintf-js@1.0.3: {} + + stackback@0.0.2: {} + + statuses@2.0.1: {} + + statuses@2.0.2: {} + + std-env@4.1.0: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + strict-event-emitter@0.5.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + string.prototype.padend@3.1.6: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.0.1 + + strip-bom@3.0.0: {} + + strip-json-comments@5.0.1: {} + + summary@2.1.0: {} + + supports-color@10.2.2: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tagged-tag@1.0.0: {} + + tapable@2.2.1: {} + + tar@7.5.16: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + terser@5.46.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.17.0 + commander: 2.20.3 + source-map-support: 0.5.21 + optional: true + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tiny-case@1.0.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tldts-core@7.0.19: {} + + tldts@7.0.19: + dependencies: + tldts-core: 7.0.19 + + to-fast-properties@2.0.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toposort@2.0.2: {} + + totalist@3.0.1: {} + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.19 + + tree-kill@1.2.2: {} + + tsdown@0.22.4(@typescript/typescript6@6.0.2): + dependencies: + ansis: 4.3.1 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.0.1 + hookable: 6.1.1 + import-without-cache: 0.4.0 + obug: 2.1.3 + picomatch: 4.0.5 + rolldown: 1.1.5 + rolldown-plugin-dts: 0.27.4(@typescript/typescript6@6.0.2)(rolldown@1.1.5) + semver: 7.8.5 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' + transitivePeerDependencies: + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - vue-tsc + + tslib@2.8.1: {} + + type-fest@2.19.0: {} + + type-fest@4.41.0: {} + + type-fest@5.2.0: + dependencies: + tagged-tag: 1.0.0 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.3 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.3 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.3 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript@6.0.3: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + udc@1.0.1: {} + + uglify-js@3.19.3: + optional: true + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.0.2 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + underscore@1.13.6: {} + + undici-types@6.21.0: {} + + undici@7.28.0: {} + + universalify@0.1.2: {} + + until-async@3.0.2: {} + + update-browserslist-db@1.1.2(browserslist@4.24.4): + dependencies: + browserslist: 4.24.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js-replace@1.0.1: {} + + utf-8-validate@6.0.3: + dependencies: + node-gyp-build: 4.8.4 + optional: true + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + + validate-npm-package-name@5.0.0: + dependencies: + builtins: 5.0.1 + + vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.15 + rollup: 4.62.0 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + jiti: 2.4.2 + terser: 5.46.0 + + vitest-browser-react@2.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + vitest: 4.1.8(@types/node@20.19.43)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0)) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + vitest@4.1.8(@types/node@20.19.43)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.43 + '@vitest/browser-playwright': 4.1.8(bufferutil@4.0.8)(msw@2.12.14(@types/node@20.19.43)(@typescript/typescript6@6.0.2))(playwright@1.55.1)(utf-8-validate@6.0.3)(vite@6.4.2(@types/node@20.19.43)(jiti@2.4.2)(terser@5.46.0))(vitest@4.1.8) + '@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8) + transitivePeerDependencies: + - msw + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + optional: true + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.0.0 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.0.10 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.19: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.1 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + widest-line@4.0.1: + dependencies: + string-width: 5.1.2 + + wordwrap@1.0.0: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + ws@8.21.0(bufferutil@4.0.8)(utf-8-validate@6.0.3): + optionalDependencies: + bufferutil: 4.0.8 + utf-8-validate: 6.0.3 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@5.0.0: {} + + yaml-ast-parser@0.0.43: {} + + yargs-parser@20.2.9: {} + + yargs-parser@21.1.1: {} + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.1.1 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yoctocolors-cjs@2.1.3: {} + + yuku-ast@0.1.7: + dependencies: + '@yuku-toolchain/types': 0.5.43 + + yuku-codegen@0.5.46: + dependencies: + '@yuku-toolchain/types': 0.5.43 + optionalDependencies: + '@yuku-codegen/binding-darwin-arm64': 0.5.46 + '@yuku-codegen/binding-darwin-x64': 0.5.46 + '@yuku-codegen/binding-freebsd-x64': 0.5.46 + '@yuku-codegen/binding-linux-arm-gnu': 0.5.46 + '@yuku-codegen/binding-linux-arm-musl': 0.5.46 + '@yuku-codegen/binding-linux-arm64-gnu': 0.5.46 + '@yuku-codegen/binding-linux-arm64-musl': 0.5.46 + '@yuku-codegen/binding-linux-x64-gnu': 0.5.46 + '@yuku-codegen/binding-linux-x64-musl': 0.5.46 + '@yuku-codegen/binding-win32-arm64': 0.5.46 + '@yuku-codegen/binding-win32-x64': 0.5.46 + + yuku-parser@0.5.46: + dependencies: + '@yuku-toolchain/types': 0.5.43 + optionalDependencies: + '@yuku-parser/binding-darwin-arm64': 0.5.46 + '@yuku-parser/binding-darwin-x64': 0.5.46 + '@yuku-parser/binding-freebsd-x64': 0.5.46 + '@yuku-parser/binding-linux-arm-gnu': 0.5.46 + '@yuku-parser/binding-linux-arm-musl': 0.5.46 + '@yuku-parser/binding-linux-arm64-gnu': 0.5.46 + '@yuku-parser/binding-linux-arm64-musl': 0.5.46 + '@yuku-parser/binding-linux-x64-gnu': 0.5.46 + '@yuku-parser/binding-linux-x64-musl': 0.5.46 + '@yuku-parser/binding-win32-arm64': 0.5.46 + '@yuku-parser/binding-win32-x64': 0.5.46 + + yup@1.3.2: + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + + zod-validation-error@3.4.0(zod@3.22.4): + dependencies: + zod: 3.22.4 + + zod@3.22.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..17bd158 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - packages/* + - spec diff --git a/readme-assets/e2b-sdk-dark.png b/readme-assets/e2b-sdk-dark.png new file mode 100644 index 0000000..8b073c6 Binary files /dev/null and b/readme-assets/e2b-sdk-dark.png differ diff --git a/readme-assets/e2b-sdk-light.png b/readme-assets/e2b-sdk-light.png new file mode 100644 index 0000000..e6c0dd1 Binary files /dev/null and b/readme-assets/e2b-sdk-light.png differ diff --git a/readme-assets/logo-black.png b/readme-assets/logo-black.png new file mode 100644 index 0000000..a498f4b Binary files /dev/null and b/readme-assets/logo-black.png differ diff --git a/readme-assets/logo-circle.png b/readme-assets/logo-circle.png new file mode 100644 index 0000000..445b20c Binary files /dev/null and b/readme-assets/logo-circle.png differ diff --git a/readme-assets/logo-white.png b/readme-assets/logo-white.png new file mode 100644 index 0000000..6f351f7 Binary files /dev/null and b/readme-assets/logo-white.png differ diff --git a/readme-assets/preview.png b/readme-assets/preview.png new file mode 100644 index 0000000..451abe0 Binary files /dev/null and b/readme-assets/preview.png differ diff --git a/skills/stripe-projects/SKILL.md b/skills/stripe-projects/SKILL.md new file mode 100644 index 0000000..a0b12f8 --- /dev/null +++ b/skills/stripe-projects/SKILL.md @@ -0,0 +1,170 @@ +--- +name: stripe-projects +description: "Use after E2B sandbox/API access has been provisioned through Stripe Projects and the user needs to use the resulting E2B API key with the E2B CLI, JavaScript SDK, Python SDK, or Code Interpreter SDK." +--- + +# E2B Stripe Projects + +Use these patterns after E2B sandbox/API access has been provisioned through Stripe Projects. + +## Credentials + +- Stripe Projects provisions E2B sandbox/API access and returns an E2B team API key. +- Use that key as `E2B_API_KEY` for sandbox creation, sandbox control, and SDK clients. +- Prefer setting `E2B_API_KEY` explicitly in runtime environments so commands and SDK calls use the intended E2B team. +- Local defaults can also come from `.env.local` or `~/.e2b/config.json`. +- In Stripe Projects checkouts, the E2B CLI may not be globally logged in. Before using `e2b sandbox ...`, prefer a Stripe Projects env pull plus per-command export instead of trying `e2b auth login`. +- Do not print secret values. `stripe projects env --json` can return redacted placeholders, so do not use it as the source for a runnable `E2B_API_KEY`. Pull the managed env file, then read only the needed E2B variables into the command environment without echoing them. + +```bash +stripe projects env --pull --yes +env_value() { + awk -F= -v key="$1" '$1 == key {sub(/^[^=]*=/, ""); gsub(/^"|"$/, ""); print; exit}' .env +} +export E2B_API_KEY="$(env_value E2B_API_KEY)" +export E2B_API_URL="$(env_value E2B_API_URL)" +export E2B_DOMAIN="$(env_value E2B_DOMAIN)" +e2b sandbox create base --detach +``` + +## Stripe Projects CLI + +Add E2B to the current Stripe project: + +```bash +stripe projects add e2b/sandboxes +``` + +Inspect or pull provisioned credentials: + +```bash +stripe projects env +stripe projects env --json +stripe projects env --pull --yes +``` + +Export the E2B API key before using the E2B CLI or SDK. Prefer `stripe projects env --pull --yes` for command execution because JSON output may redact secret values. Read only the variables you need from `.env`; do not source `.env` as shell code: + +```bash +stripe projects env --pull --yes +env_value() { + awk -F= -v key="$1" '$1 == key {sub(/^[^=]*=/, ""); gsub(/^"|"$/, ""); print; exit}' .env +} +export E2B_API_KEY="$(env_value E2B_API_KEY)" +export E2B_API_URL="$(env_value E2B_API_URL)" +export E2B_DOMAIN="$(env_value E2B_DOMAIN)" +``` + +Use `stripe projects env --json` for structure checks only. In current Stripe Projects output, the E2B variables are under `data.resource_access_configurations[].access_configuration`, including `E2B_API_KEY`, `E2B_API_URL`, and `E2B_DOMAIN`, but the values may be redacted and unusable for CLI/SDK calls. + +## E2B CLI + +If the E2B CLI is missing or a shell shim is broken, install it with Node instead of switching to SDK code: + +```bash +if ! e2b --version >/dev/null 2>&1; then + npm install -g @e2b/cli +fi +``` + +The CLI package requires Node 20 or newer. + +Prefer detached sandboxes for non-interactive work so the command returns a sandbox ID and does not attach an interactive terminal: + +```bash +e2b sandbox create --detach +e2b sandbox create base --detach +``` + +Run shell commands in a sandbox with `--` before shell flags so the CLI does not parse them: + +```bash +e2b sandbox exec -- bash -lc 'pwd && ls -la' +``` + +For long-running processes, run them in the background and write a PID file in the sandbox: + +```bash +e2b sandbox exec -- bash -lc 'nohup python server.py >server.log 2>&1 & echo $! >server.pid' +``` + +The CLI has explicit resume, but sandbox creation does not currently expose an auto-resume flag: + +```bash +e2b sandbox resume +``` + +Use `sandbox exec` for operational commands against an existing sandbox: + +```bash +e2b sandbox exec -- bash -lc 'python --version' +e2b sandbox exec -- bash -lc 'ls -la /tmp' +``` + +## JavaScript SDK + +Use `Sandbox.create()` with `E2B_API_KEY` for a fresh sandbox: + +```ts +import Sandbox from 'e2b' + +const sandbox = await Sandbox.create('base') +const result = await sandbox.commands.run('echo "hello from e2b"') +console.log(result.stdout) +``` + +Use lifecycle options when the sandbox should pause on timeout and resume on later activity: + +```ts +const sandbox = await Sandbox.create('base', { + lifecycle: { onTimeout: 'pause', autoResume: true }, +}) +``` + +Use `Sandbox.connect()` when a CLI step already created the sandbox: + +```ts +import Sandbox from 'e2b' + +const sandbox = await Sandbox.connect(process.env.SANDBOX_ID!) +const result = await sandbox.commands.run('cat important_file.md') +console.log(result.stdout) +``` + +## Python SDK + +Use the context manager with `E2B_API_KEY` for short-lived tasks: + +```py +from e2b import Sandbox + +with Sandbox.create("base") as sandbox: + result = sandbox.commands.run('echo "hello from e2b"') + print(result.stdout) +``` + +Use lifecycle options when the sandbox should pause on timeout and resume on later activity: + +```py +sandbox = Sandbox.create( + "base", + lifecycle={"on_timeout": "pause", "auto_resume": True}, +) +``` + +For workflows that share a sandbox across steps, keep the sandbox ID and connect to it later: + +```py +from e2b import Sandbox + +sandbox = Sandbox.connect(sandbox_id) +result = sandbox.commands.run("cat important_file.md") +print(result.stdout) +``` + +## Practical Defaults + +- Use the `base` template unless a task requires a specific template. +- Prefer `bash -lc` for multi-command CLI execution. +- Put temporary files under `/tmp` or the current working directory inside the sandbox unless the task requires a specific path. +- For workloads that need code execution with `run_code` or `runCode`, use the Code Interpreter SDK. diff --git a/spec/envd/buf-js.gen.yaml b/spec/envd/buf-js.gen.yaml new file mode 100644 index 0000000..397146c --- /dev/null +++ b/spec/envd/buf-js.gen.yaml @@ -0,0 +1,16 @@ +# buf.gen.yaml defines a local generation template. +# For details, see https://buf.build/docs/configuration/v1/buf-gen-yaml +version: v1 +plugins: + - plugin: es + out: ../../packages/js-sdk/src/envd + opt: + - target=ts + - plugin: connect-es + out: ../../packages/js-sdk/src/envd + opt: + - target=ts + +managed: + enabled: true + optimize_for: SPEED diff --git a/spec/envd/buf-python.gen.yaml b/spec/envd/buf-python.gen.yaml new file mode 100644 index 0000000..e84a2a8 --- /dev/null +++ b/spec/envd/buf-python.gen.yaml @@ -0,0 +1,15 @@ +# buf.gen.yaml defines a local generation template. +# For details, see https://buf.build/docs/configuration/v1/buf-gen-yaml +version: v1 +plugins: + - plugin: python + out: ../../packages/python-sdk/e2b/envd + opt: + - pyi_out=../../packages/python-sdk/e2b/envd + - name: connect-python + out: ../../packages/python-sdk/e2b/envd + path: protoc-gen-connect-python + +managed: + enabled: true + optimize_for: SPEED diff --git a/spec/envd/envd.yaml b/spec/envd/envd.yaml new file mode 100644 index 0000000..6b776a7 --- /dev/null +++ b/spec/envd/envd.yaml @@ -0,0 +1,312 @@ +openapi: 3.0.0 +info: + title: envd + version: 0.1.3 + description: API for managing files' content and controlling envd + +tags: + - name: files + +paths: + /health: + get: + summary: Check the health of the service + responses: + '204': + description: The service is healthy + + /metrics: + get: + summary: Get the stats of the service + security: + - AccessTokenAuth: [] + - {} + responses: + '200': + description: The resource usage metrics of the service + content: + application/json: + schema: + $ref: '#/components/schemas/Metrics' + + /init: + post: + summary: Set initial vars, ensure the time and metadata is synced with the host + security: + - AccessTokenAuth: [] + - {} + requestBody: + content: + application/json: + schema: + type: object + properties: + hyperloopIP: + type: string + description: IP address of the hyperloop server to connect to + envVars: + $ref: '#/components/schemas/EnvVars' + accessToken: + type: string + description: Access token for secure access to envd service + timestamp: + type: string + format: date-time + description: The current timestamp in RFC3339 format + defaultUser: + type: string + description: The default user to use for operations + defaultWorkdir: + type: string + description: The default working directory to use for operations + responses: + '204': + description: Env vars set, the time and metadata is synced with the host + + /envs: + get: + summary: Get the environment variables + security: + - AccessTokenAuth: [] + - {} + responses: + '200': + description: Environment variables + content: + application/json: + schema: + $ref: '#/components/schemas/EnvVars' + + /files: + get: + summary: Download a file + tags: [files] + security: + - AccessTokenAuth: [] + - {} + parameters: + - $ref: '#/components/parameters/FilePath' + - $ref: '#/components/parameters/User' + - $ref: '#/components/parameters/Signature' + - $ref: '#/components/parameters/SignatureExpiration' + responses: + '200': + $ref: '#/components/responses/DownloadSuccess' + '401': + $ref: '#/components/responses/InvalidUser' + '400': + $ref: '#/components/responses/InvalidPath' + '404': + $ref: '#/components/responses/FileNotFound' + '500': + $ref: '#/components/responses/InternalServerError' + post: + summary: Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. + description: | + Any request header of the form `X-Metadata-: ` is persisted + as a user-defined extended attribute on the uploaded file. The + `X-Metadata-` prefix is stripped and the remaining header name is + lowercased to form the metadata key; the resulting map is returned on + `EntryInfo` lookups (e.g. `Stat`, `ListDir`). + + Each upload replaces the file's metadata with the keys provided in + that request: keys previously stored but absent from the new request + are removed, and an upload that sends no `X-Metadata-*` header clears + all existing metadata. + + Both keys and values must be printable US-ASCII (bytes `0x20`-`0x7E`) + and are rejected with HTTP 400 otherwise. Each key is capped at 246 + bytes (the Linux VFS xattr-name limit minus the namespace prefix), and + the combined size of all metadata on a file (keys plus values, with the + namespace prefix counted per key) is capped at 4096 bytes to stay within + the filesystem's per-inode xattr budget. Multiple files in a single + multipart upload receive the same metadata. If the same + `X-Metadata-` header is sent more than once, only the first + value is used. + tags: [files] + security: + - AccessTokenAuth: [] + - {} + parameters: + - $ref: '#/components/parameters/FilePath' + - $ref: '#/components/parameters/User' + - $ref: '#/components/parameters/Signature' + - $ref: '#/components/parameters/SignatureExpiration' + requestBody: + $ref: '#/components/requestBodies/File' + responses: + '200': + $ref: '#/components/responses/UploadSuccess' + '400': + $ref: '#/components/responses/InvalidPath' + '401': + $ref: '#/components/responses/InvalidUser' + '500': + $ref: '#/components/responses/InternalServerError' + '507': + $ref: '#/components/responses/NotEnoughDiskSpace' + +components: + securitySchemes: + AccessTokenAuth: + type: apiKey + scheme: header + name: X-Access-Token + + parameters: + FilePath: + name: path + in: query + required: false + description: Path to the file, URL encoded. Can be relative to user's home directory. + schema: + type: string + User: + name: username + in: query + required: false + description: User used for setting the owner, or resolving relative paths. + schema: + type: string + Signature: + name: signature + in: query + required: false + description: Signature used for file access permission verification. + schema: + type: string + SignatureExpiration: + name: signature_expiration + in: query + required: false + description: Signature expiration used for defining the expiration time of the signature. + schema: + type: integer + + requestBodies: + File: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + + responses: + UploadSuccess: + description: The file was uploaded successfully. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/EntryInfo' + + DownloadSuccess: + description: Entire file downloaded successfully. + content: + application/octet-stream: + schema: + type: string + format: binary + description: The file content + InvalidPath: + description: Invalid path + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + InternalServerError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + FileNotFound: + description: File not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + InvalidUser: + description: Invalid user + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + NotEnoughDiskSpace: + description: Not enough disk space + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + schemas: + Error: + required: + - message + - code + properties: + message: + type: string + description: Error message + code: + type: integer + description: Error code + EntryInfo: + required: + - path + - name + - type + properties: + path: + type: string + description: Path to the file + name: + type: string + description: Name of the file + type: + type: string + description: Type of the file + enum: + - file + metadata: + type: object + description: User-defined metadata stored as extended attributes on the file. + additionalProperties: + type: string + EnvVars: + type: object + description: Environment variables to set + additionalProperties: + type: string + Metrics: + type: object + description: Resource usage metrics + properties: + ts: + type: integer + format: int64 + description: Unix timestamp in UTC for current sandbox time + cpu_count: + type: integer + description: Number of CPU cores + cpu_used_pct: + type: number + format: float + description: CPU usage percentage + mem_total: + type: integer + description: Total virtual memory in bytes + mem_used: + type: integer + description: Used virtual memory in bytes + disk_used: + type: integer + description: Used disk space in bytes + disk_total: + type: integer + description: Total disk space in bytes diff --git a/spec/envd/filesystem/filesystem.proto b/spec/envd/filesystem/filesystem.proto new file mode 100644 index 0000000..e97b687 --- /dev/null +++ b/spec/envd/filesystem/filesystem.proto @@ -0,0 +1,152 @@ +syntax = "proto3"; + +package filesystem; + +import "google/protobuf/timestamp.proto"; + +service Filesystem { + rpc Stat(StatRequest) returns (StatResponse); + rpc MakeDir(MakeDirRequest) returns (MakeDirResponse); + rpc Move(MoveRequest) returns (MoveResponse); + rpc ListDir(ListDirRequest) returns (ListDirResponse); + rpc Remove(RemoveRequest) returns (RemoveResponse); + + rpc WatchDir(WatchDirRequest) returns (stream WatchDirResponse); + + // Non-streaming versions of WatchDir + rpc CreateWatcher(CreateWatcherRequest) returns (CreateWatcherResponse); + rpc GetWatcherEvents(GetWatcherEventsRequest) returns (GetWatcherEventsResponse); + rpc RemoveWatcher(RemoveWatcherRequest) returns (RemoveWatcherResponse); +} + +message MoveRequest { + string source = 1; + string destination = 2; +} + +message MoveResponse { + EntryInfo entry = 1; +} + +message MakeDirRequest { + string path = 1; +} + +message MakeDirResponse { + EntryInfo entry = 1; +} + +message RemoveRequest { + string path = 1; +} + +message RemoveResponse {} + +message StatRequest { + string path = 1; +} + +message StatResponse { + EntryInfo entry = 1; +} + +message EntryInfo { + string name = 1; + FileType type = 2; + string path = 3; + int64 size = 4; + uint32 mode = 5; + string permissions = 6; + string owner = 7; + string group = 8; + google.protobuf.Timestamp modified_time = 9; + // If the entry is a symlink, this field contains the target of the symlink. + optional string symlink_target = 10; + // User-defined metadata stored as extended attributes (xattrs) on the file. + // Keys live under the `user.e2b.` xattr namespace; the prefix is stripped here. + // Plain `user.*` xattrs written by other tooling are not reflected. + map metadata = 11; +} + +enum FileType { + FILE_TYPE_UNSPECIFIED = 0; + FILE_TYPE_FILE = 1; + FILE_TYPE_DIRECTORY = 2; +} + +message ListDirRequest { + string path = 1; + uint32 depth = 2; +} + +message ListDirResponse { + repeated EntryInfo entries = 1; +} + +message WatchDirRequest { + string path = 1; + bool recursive = 2; + // If true, each FilesystemEvent includes the EntryInfo of the affected entry, when available. + bool include_entry = 3; + // If true, allows watching paths on network filesystem mounts (NFS, CIFS, SMB, FUSE). + // Events on network mounts may be unreliable or not delivered at all. + bool allow_network_mounts = 4; +} + +message FilesystemEvent { + string name = 1; + EventType type = 2; + // Info of the entry that triggered the event. Only populated when include_entry + // was requested and the entry could be stat-ed (e.g. not set for remove/rename-away + // events, where the entry no longer exists at this path). + optional EntryInfo entry = 3; +} + +message WatchDirResponse { + oneof event { + StartEvent start = 1; + FilesystemEvent filesystem = 2; + KeepAlive keepalive = 3; + } + + message StartEvent {} + + message KeepAlive {} +} + +message CreateWatcherRequest { + string path = 1; + bool recursive = 2; + // If true, each FilesystemEvent includes the EntryInfo of the affected entry, when available. + bool include_entry = 3; + // If true, allows watching paths on network filesystem mounts (NFS, CIFS, SMB, FUSE). + // Events on network mounts may be unreliable or not delivered at all. + bool allow_network_mounts = 4; +} + +message CreateWatcherResponse { + string watcher_id = 1; +} + +message GetWatcherEventsRequest { + string watcher_id = 1; +} + +message GetWatcherEventsResponse { + repeated FilesystemEvent events = 1; +} + +message RemoveWatcherRequest { + string watcher_id = 1; +} + +message RemoveWatcherResponse {} + +enum EventType { + EVENT_TYPE_UNSPECIFIED = 0; + EVENT_TYPE_CREATE = 1; + EVENT_TYPE_WRITE = 2; + EVENT_TYPE_REMOVE = 3; + EVENT_TYPE_RENAME = 4; + EVENT_TYPE_CHMOD = 5; +} diff --git a/spec/envd/process/process.proto b/spec/envd/process/process.proto new file mode 100644 index 0000000..4d394da --- /dev/null +++ b/spec/envd/process/process.proto @@ -0,0 +1,169 @@ +syntax = "proto3"; + +package process; + +service Process { + rpc List(ListRequest) returns (ListResponse); + + rpc Connect(ConnectRequest) returns (stream ConnectResponse); + rpc Start(StartRequest) returns (stream StartResponse); + + rpc Update(UpdateRequest) returns (UpdateResponse); + + // Client input stream ensures ordering of messages + rpc StreamInput(stream StreamInputRequest) returns (StreamInputResponse); + rpc SendInput(SendInputRequest) returns (SendInputResponse); + rpc SendSignal(SendSignalRequest) returns (SendSignalResponse); + + // Close stdin to signal EOF to the process. + // Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead. + rpc CloseStdin(CloseStdinRequest) returns (CloseStdinResponse); +} + +message PTY { + Size size = 1; + + message Size { + uint32 cols = 1; + uint32 rows = 2; + } +} + +message ProcessConfig { + string cmd = 1; + repeated string args = 2; + + map envs = 3; + optional string cwd = 4; +} + +message ListRequest {} + +message ProcessInfo { + ProcessConfig config = 1; + uint32 pid = 2; + optional string tag = 3; +} + +message ListResponse { + repeated ProcessInfo processes = 1; +} + +message StartRequest { + ProcessConfig process = 1; + optional PTY pty = 2; + optional string tag = 3; + optional bool stdin = 4; +} + +message UpdateRequest { + ProcessSelector process = 1; + + optional PTY pty = 2; +} + +message UpdateResponse {} + +message ProcessEvent { + oneof event { + StartEvent start = 1; + DataEvent data = 2; + EndEvent end = 3; + KeepAlive keepalive = 4; + } + + message StartEvent { + uint32 pid = 1; + } + + message DataEvent { + oneof output { + bytes stdout = 1; + bytes stderr = 2; + bytes pty = 3; + } + } + + message EndEvent { + sint32 exit_code = 1; + bool exited = 2; + string status = 3; + optional string error = 4; + } + + message KeepAlive {} +} + +message StartResponse { + ProcessEvent event = 1; +} + +message ConnectResponse { + ProcessEvent event = 1; +} + +message SendInputRequest { + ProcessSelector process = 1; + + ProcessInput input = 2; +} + +message SendInputResponse {} + +message ProcessInput { + oneof input { + bytes stdin = 1; + bytes pty = 2; + } +} + +message StreamInputRequest { + oneof event { + StartEvent start = 1; + DataEvent data = 2; + KeepAlive keepalive = 3; + } + + message StartEvent { + ProcessSelector process = 1; + } + + message DataEvent { + ProcessInput input = 2; + } + + message KeepAlive {} +} + +message StreamInputResponse {} + +enum Signal { + SIGNAL_UNSPECIFIED = 0; + SIGNAL_SIGTERM = 15; + SIGNAL_SIGKILL = 9; +} + +message SendSignalRequest { + ProcessSelector process = 1; + + Signal signal = 2; +} + +message SendSignalResponse {} + +message CloseStdinRequest { + ProcessSelector process = 1; +} + +message CloseStdinResponse {} + +message ConnectRequest { + ProcessSelector process = 1; +} + +message ProcessSelector { + oneof selector { + uint32 pid = 1; + string tag = 2; + } +} diff --git a/spec/mcp-server.json b/spec/mcp-server.json new file mode 100644 index 0000000..6c994b6 --- /dev/null +++ b/spec/mcp-server.json @@ -0,0 +1,3746 @@ +{ + "additionalProperties": false, + "properties": { + "airtable": { + "title": "Airtable MCP Server", + "description": "Provides AI assistants with direct access to Airtable bases, allowing them to read schemas, query records, and interact with your Airtable data. Supports listing bases, retrieving table structures, and searching through records to help automate workflows and answer questions about your organized data.", + "required": [ + "airtableApiKey", + "nodeenv" + ], + "additionalProperties": false, + "properties": { + "airtableApiKey": { + "type": "string" + }, + "nodeenv": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/airtable-mcp-server/overview" + }, + "aks": { + "title": "Azure Kubernetes Service (AKS)", + "description": "Azure Kubernetes Service (AKS) official MCP server.", + "required": [ + "azureDir", + "kubeconfig", + "accessLevel" + ], + "additionalProperties": false, + "properties": { + "accessLevel": { + "description": "Access level for the MCP server, One of [ readonly, readwrite, admin ]", + "type": "string" + }, + "additionalTools": { + "description": "Comma-separated list of additional tools, One of [ helm, cilium ]", + "type": "string" + }, + "allowNamespaces": { + "description": "Comma-separated list of namespaces to allow access to. If not specified, all namespaces are allowed.", + "type": "string" + }, + "azureDir": { + "description": "Path to the Azure configuration directory (e.g. /home/azureuser/.azure). Used for Azure CLI authentication, you should be logged in (e.g. run `az login`) on the host before starting the MCP server.", + "type": "string" + }, + "containerUser": { + "description": "Username or UID of the container user (format \u003cname|uid\u003e[:\u003cgroup|gid\u003e] e.g. 10000), ensuring correct permissions to access the Azure and kubeconfig files. Leave empty to use default user in the container.", + "type": "string" + }, + "kubeconfig": { + "description": "Path to the kubeconfig file for the AKS cluster (e.g. /home/azureuser/.kube/config). Used to connect to the AKS cluster.", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/aks/overview" + }, + "apiGateway": { + "title": "Api-gateway", + "description": "A universal MCP (Model Context Protocol) server to integrate any API with Claude Desktop using only Docker configurations.", + "required": [ + "api1HeaderAuthorization", + "api1Name", + "api1SwaggerUrl" + ], + "additionalProperties": false, + "properties": { + "api1HeaderAuthorization": { + "type": "string" + }, + "api1Name": { + "type": "string" + }, + "api1SwaggerUrl": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mcp-api-gateway/overview" + }, + "apify": { + "title": "Apify MCP Server", + "description": "Apify is the world's largest marketplace of tools for web scraping, data extraction, and web automation. You can extract structured data from social media, e-commerce, search engines, maps, travel sites, or any other website.", + "required": [ + "apifyToken", + "tools" + ], + "additionalProperties": false, + "properties": { + "apifyToken": { + "type": "string" + }, + "tools": { + "description": "Comma-separated list of tools to enable. Can be either a tool category, a specific tool, or an Apify Actor. For example: \"actors,docs,apify/rag-web-browser\". For more details visit https://mcp.apify.com.", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/apify-mcp-server/overview" + }, + "arxiv": { + "title": "ArXiv MCP Server", + "description": "The ArXiv MCP Server provides a comprehensive bridge between AI assistants and arXiv's research repository through the Model Context Protocol (MCP). Features: • Search arXiv papers with advanced filtering • Download and store papers locally as markdown • Read and analyze paper content • Deep research analysis prompts • Local paper management and storage • Enhanced tool descriptions optimized for local AI models • Docker MCP Gateway compatible with detailed context Perfect for researchers, academics, and AI assistants conducting literature reviews and research analysis. **Recent Update**: Enhanced tool descriptions specifically designed to resolve local AI model confusion and improve Docker MCP Gateway compatibility.", + "required": [ + "storagePath" + ], + "additionalProperties": false, + "properties": { + "storagePath": { + "description": "Directory path where downloaded papers will be stored", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/arxiv-mcp-server/overview" + }, + "astGrep": { + "title": "ast-grep", + "description": "ast-grep is a fast and polyglot tool for code structural search, lint, rewriting at large scale.", + "required": [ + "path" + ], + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/ast-grep/overview" + }, + "astraDb": { + "title": "Astra DB", + "description": "An MCP server for Astra DB workloads.", + "required": [ + "astraDbApplicationToken", + "endpoint" + ], + "additionalProperties": false, + "properties": { + "astraDbApplicationToken": { + "type": "string" + }, + "endpoint": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/astra-db/overview" + }, + "astroDocs": { + "title": "Astro Docs", + "description": "Access the latest Astro web framework documentation, guides, and API references.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/astro-docs/overview" + }, + "atlan": { + "title": "Atlan MCP Server", + "description": "MCP server for interacting with Atlan services including asset search, updates, and lineage traversal for comprehensive data governance and discovery.", + "required": [ + "apiKey", + "baseUrl" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "baseUrl": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/atlan/overview" + }, + "atlasDocs": { + "title": "Atlas Docs", + "description": "Provide LLMs hosted, clean markdown documentation of libraries and frameworks.", + "required": [ + "apiUrl" + ], + "additionalProperties": false, + "properties": { + "apiUrl": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/atlas-docs/overview" + }, + "atlassian": { + "title": "Atlassian", + "description": "Tools for Atlassian products (Confluence and Jira). This integration supports both Atlassian Cloud and Jira Server/Data Center deployments.", + "required": [ + "jiraUrl", + "confluenceUrl" + ], + "additionalProperties": false, + "properties": { + "confluenceApiToken": { + "type": "string" + }, + "confluencePersonalToken": { + "type": "string" + }, + "confluenceUrl": { + "type": "string" + }, + "confluenceUsername": { + "type": "string" + }, + "jiraApiToken": { + "type": "string" + }, + "jiraPersonalToken": { + "type": "string" + }, + "jiraUrl": { + "type": "string" + }, + "jiraUsername": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/atlassian/overview" + }, + "audienseInsights": { + "title": "Audiense Insights", + "description": "Audiense Insights MCP Server is a server based on the Model Context Protocol (MCP) that allows Claude and other MCP-compatible clients to interact with your Audiense Insights account.", + "required": [ + "clientId" + ], + "additionalProperties": false, + "properties": { + "audienseClientSecret": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "twitterBearerToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/audiense-insights/overview" + }, + "awsCdk": { + "title": "AWS CDK", + "description": "AWS Cloud Development Kit (CDK) best practices, infrastructure as code patterns, and security compliance with CDK Nag.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/aws-cdk-mcp-server/overview" + }, + "awsCore": { + "title": "AWS Core MCP Server", + "description": "Starting point for using the awslabs MCP servers.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/aws-core-mcp-server/overview" + }, + "awsDiagram": { + "title": "AWS Diagram", + "description": "Seamlessly create diagrams using the Python diagrams package DSL. This server allows you to generate AWS diagrams, sequence diagrams, flow diagrams, and class diagrams using Python code.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/aws-diagram/overview" + }, + "awsDocumentation": { + "title": "AWS Documentation", + "description": "Tools to access AWS documentation, search for content, and get recommendations.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/aws-documentation/overview" + }, + "awsKbRetrievalServer": { + "title": "AWS KB Retrieval (Archived)", + "description": "An MCP server implementation for retrieving information from the AWS Knowledge Base using the Bedrock Agent Runtime.", + "required": [ + "accessKeyId" + ], + "additionalProperties": false, + "properties": { + "accessKeyId": { + "type": "string" + }, + "awsSecretAccessKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/aws-kb-retrieval-server/overview" + }, + "awsTerraform": { + "title": "AWS Terraform", + "description": "Terraform on AWS best practices, infrastructure as code patterns, and security compliance with Checkov.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/aws-terraform/overview" + }, + "azure": { + "title": "Azure", + "description": "The Azure MCP Server, bringing the power of Azure to your agents.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/azure/overview" + }, + "beagleSecurity": { + "title": "Beagle security MCP server", + "description": "Connects with the Beagle Security backend using a user token to manage applications, run automated security tests, track vulnerabilities across environments, and gain intelligence from Application and API vulnerability data.", + "required": [ + "beagleSecurityApiToken" + ], + "additionalProperties": false, + "properties": { + "beagleSecurityApiToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/beagle-security/overview" + }, + "bitrefill": { + "title": "Bitrefill", + "description": "A Model Context Protocol Server connector for Bitrefill public API, to enable AI agents to search and shop on Bitrefill.", + "required": [ + "apiId" + ], + "additionalProperties": false, + "properties": { + "apiId": { + "type": "string" + }, + "apiSecret": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/bitrefill/overview" + }, + "box": { + "title": "Box", + "description": "An MCP server capable of interacting with the Box API.", + "required": [ + "clientId" + ], + "additionalProperties": false, + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/box/overview" + }, + "brave": { + "title": "Brave Search", + "description": "Search the Web for pages, images, news, videos, and more using the Brave Search API.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/brave/overview" + }, + "browserbase": { + "title": "Browserbase", + "description": "Allow LLMs to control a browser with Browserbase and Stagehand for AI-powered web automation, intelligent data extraction, and screenshot capture.", + "required": [ + "apiKey", + "geminiApiKey", + "projectId" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "geminiApiKey": { + "type": "string" + }, + "projectId": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/browserbase/overview" + }, + "buildkite": { + "title": "Buildkite", + "description": "Buildkite MCP lets agents interact with Buildkite Builds, Jobs, Logs, Packages and Test Suites.", + "required": [ + "apiToken" + ], + "additionalProperties": false, + "properties": { + "apiToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/buildkite/overview" + }, + "camunda": { + "title": "Camunda BPM process engine MCP Server", + "description": "Tools to interact with the Camunda 7 Community Edition Engine using the Model Context Protocol (MCP). Whether you're automating workflows, querying process instances, or integrating with external systems, Camunda MCP Server is your agentic solution for seamless interaction with Camunda.", + "required": [ + "camundahost" + ], + "additionalProperties": false, + "properties": { + "camundahost": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/camunda/overview" + }, + "cdataConnectcloud": { + "title": "CData Connect Cloud", + "description": "This fully functional MCP Server allows you to connect to any data source in Connect Cloud from Claude Desktop.", + "required": [ + "username" + ], + "additionalProperties": false, + "properties": { + "cdataPat": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/cdata-connectcloud/overview" + }, + "charmhealth": { + "title": "CharmHealth MCP Server", + "description": "An MCP server for CharmHealth EHR that allows LLMs and MCP clients to interact with patient records, encounters, and practice information.", + "required": [ + "charmhealthApiKey", + "charmhealthBaseUrl", + "charmhealthClientId", + "charmhealthClientSecret", + "charmhealthRedirectUri", + "charmhealthRefreshToken", + "charmhealthTokenUrl" + ], + "additionalProperties": false, + "properties": { + "charmhealthApiKey": { + "type": "string" + }, + "charmhealthBaseUrl": { + "type": "string" + }, + "charmhealthClientId": { + "type": "string" + }, + "charmhealthClientSecret": { + "type": "string" + }, + "charmhealthRedirectUri": { + "type": "string" + }, + "charmhealthRefreshToken": { + "type": "string" + }, + "charmhealthTokenUrl": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/charmhealth-mcp-server/overview" + }, + "chroma": { + "title": "Chroma", + "description": "A Model Context Protocol (MCP) server implementation that provides database capabilities for Chroma.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/chroma/overview" + }, + "circleci": { + "title": "CircleCI", + "description": "A specialized server implementation for the Model Context Protocol (MCP) designed to integrate with CircleCI's development workflow. This project serves as a bridge between CircleCI's infrastructure and the Model Context Protocol, enabling enhanced AI-powered development experiences.", + "required": [ + "token", + "url" + ], + "additionalProperties": false, + "properties": { + "token": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/circleci/overview" + }, + "clickhouse": { + "title": "Official ClickHouse MCP Server", + "description": "Official ClickHouse MCP Server.", + "required": [ + "connectTimeout", + "host", + "password", + "port", + "secure", + "sendReceiveTimeout", + "user", + "verify" + ], + "additionalProperties": false, + "properties": { + "connectTimeout": { + "type": "string" + }, + "host": { + "type": "string" + }, + "password": { + "type": "string" + }, + "port": { + "type": "string" + }, + "secure": { + "type": "string" + }, + "sendReceiveTimeout": { + "type": "string" + }, + "user": { + "type": "string" + }, + "verify": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/clickhouse/overview" + }, + "close": { + "title": "Close", + "description": "Streamline sales processes with integrated calling, email, SMS, and automated workflows for small and scaling businesses.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/close/overview" + }, + "cloudRun": { + "title": "Cloud Run MCP", + "description": "MCP server to deploy apps to Cloud Run.", + "required": [ + "credentialsPath" + ], + "additionalProperties": false, + "properties": { + "credentialsPath": { + "description": "path to application-default credentials (eg $HOME/.config/gcloud/application_default_credentials.json )", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/cloud-run-mcp/overview" + }, + "cloudflareDocs": { + "title": "Cloudflare Docs", + "description": "Access the latest documentation on Cloudflare products such as Workers, Pages, R2, D1, KV.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/cloudflare-docs/overview" + }, + "cockroachdb": { + "title": "CockroachDB", + "description": "Enable AI agents to manage, monitor, and query CockroachDB using natural language. Perform complex database operations, cluster management, and query execution seamlessly through AI-driven workflows. Integrate effortlessly with MCP clients for scalable and high-performance data operations.", + "required": [ + "caPath", + "crdbPwd", + "database", + "host", + "port", + "sslCertfile", + "sslKeyfile", + "sslMode", + "username" + ], + "additionalProperties": false, + "properties": { + "caPath": { + "type": "string" + }, + "crdbPwd": { + "type": "string" + }, + "database": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "sslCertfile": { + "type": "string" + }, + "sslKeyfile": { + "type": "string" + }, + "sslMode": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/cockroachdb/overview" + }, + "codeInterpreter": { + "title": "Python Interpreter", + "description": "A Python-based execution tool that mimics a Jupyter notebook environment. It accepts code snippets, executes them, and maintains state across sessions — preserving variables, imports, and past results. Ideal for iterative development, debugging, or code execution.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mcp-code-interpreter/overview" + }, + "context7": { + "title": "Context7", + "description": "Context7 MCP Server -- Up-to-date code documentation for LLMs and AI code editors.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/context7/overview" + }, + "couchbase": { + "title": "Couchbase", + "description": "Couchbase is a distributed document database with a powerful search engine and in-built operational and analytical capabilities.", + "required": [ + "cbBucketName", + "cbConnectionString", + "cbMcpReadOnlyQueryMode", + "cbPassword", + "cbUsername" + ], + "additionalProperties": false, + "properties": { + "cbBucketName": { + "description": "Bucket in the Couchbase cluster to use for the MCP server.", + "type": "string" + }, + "cbConnectionString": { + "description": "Connection string for the Couchbase cluster.", + "type": "string" + }, + "cbMcpReadOnlyQueryMode": { + "description": "Setting to \"true\" (default) enables read-only query mode while running SQL++ queries.", + "type": "string" + }, + "cbPassword": { + "type": "string" + }, + "cbUsername": { + "description": "Username for the Couchbase cluster with access to the bucket.", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/couchbase/overview" + }, + "cylera": { + "title": "The official MCP Server for Cylera.", + "description": "Brings context about device inventory, threats, risks and utilization powered by the Cylera Partner API into an LLM.", + "required": [ + "cyleraBaseUrl", + "cyleraPassword", + "cyleraUsername" + ], + "additionalProperties": false, + "properties": { + "cyleraBaseUrl": { + "type": "string" + }, + "cyleraPassword": { + "type": "string" + }, + "cyleraUsername": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/cylera-mcp-server/overview" + }, + "cyreslabAiShodan": { + "title": "Shodan", + "description": "A Model Context Protocol server that provides access to Shodan API functionality.", + "required": [ + "shodanApiKey" + ], + "additionalProperties": false, + "properties": { + "shodanApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/cyreslab-ai-shodan/overview" + }, + "dappier": { + "title": "Dappier", + "description": "Enable fast, free real-time web search and access premium data from trusted media brands—news, financial markets, sports, entertainment, weather, and more. Build powerful AI agents with Dappier.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/dappier/overview" + }, + "dappierRemote": { + "title": "Dappier Remote MCP Server", + "description": "Enable fast, free real-time web search and access premium data from trusted media brands—news, financial markets, sports, entertainment, weather, and more. Build powerful AI agents with Dappier.", + "required": [ + "dappierRemoteApiKey" + ], + "additionalProperties": false, + "properties": { + "dappierRemoteApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/dappier-remote/overview" + }, + "dart": { + "title": "Dart AI", + "description": "Dart AI Model Context Protocol (MCP) server.", + "required": [ + "host", + "token" + ], + "additionalProperties": false, + "properties": { + "host": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/dart/overview" + }, + "databaseServer": { + "title": "MCP Database Server", + "description": "Comprehensive database server supporting PostgreSQL, MySQL, and SQLite with natural language SQL query capabilities. Enables AI agents to interact with databases through both direct SQL and natural language queries.", + "required": [ + "databaseUrl" + ], + "additionalProperties": false, + "properties": { + "databaseUrl": { + "description": "Connection string for your database. Examples: SQLite: sqlite+aiosqlite:///data/mydb.db, PostgreSQL: postgresql+asyncpg://user:password@localhost:5432/mydb, MySQL: mysql+aiomysql://user:password@localhost:3306/mydb", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/database-server/overview" + }, + "databutton": { + "title": "Databutton", + "description": "Databutton MCP Server.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/databutton/overview" + }, + "deepwiki": { + "title": "DeepWiki", + "description": "Tools for fetching and asking questions about GitHub repositories.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/deepwiki/overview" + }, + "descope": { + "title": "Descope", + "description": "The Descope Model Context Protocol (MCP) server provides an interface to interact with Descope's Management APIs, enabling the search and retrieval of project-related information.", + "required": [ + "projectId" + ], + "additionalProperties": false, + "properties": { + "managementKey": { + "type": "string" + }, + "projectId": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/descope/overview" + }, + "desktopCommander": { + "title": "Desktop Commander", + "description": "Search, update, manage files and run terminal commands with AI.", + "required": [ + "paths" + ], + "additionalProperties": false, + "properties": { + "paths": { + "description": "List of directories that Desktop Commander can access", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/desktop-commander/overview" + }, + "devhubCms": { + "title": "DevHub CMS", + "description": "DevHub CMS LLM integration through the Model Context Protocol.", + "required": [ + "url" + ], + "additionalProperties": false, + "properties": { + "devhubApiKey": { + "type": "string" + }, + "devhubApiSecret": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/devhub-cms/overview" + }, + "discord": { + "title": "Discord", + "description": "Interact with the Discord platform.", + "required": [ + "discordToken" + ], + "additionalProperties": false, + "properties": { + "discordToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mcp-discord/overview" + }, + "dockerhub": { + "title": "Docker Hub", + "description": "Docker Hub official MCP server.", + "required": [ + "hubPatToken", + "username" + ], + "additionalProperties": false, + "properties": { + "hubPatToken": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/dockerhub/overview" + }, + "dodoPayments": { + "title": "Dodo Payments", + "description": "Tools for cross-border payments, taxes, and compliance.", + "required": [ + "dodoPaymentsApiKey" + ], + "additionalProperties": false, + "properties": { + "dodoPaymentsApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/dodo-payments/overview" + }, + "dreamfactory": { + "title": "DreamFactory MCP Server", + "description": "DreamFactory is a REST API generation platform with support for hundreds of data sources, including Microsoft SQL Server, MySQL, PostgreSQL, and MongoDB. The DreamFactory MCP Server makes it easy for users to securely interact with their data sources via an MCP client.", + "required": [ + "dreamfactoryapikey", + "dreamfactoryurl" + ], + "additionalProperties": false, + "properties": { + "dreamfactoryapikey": { + "type": "string" + }, + "dreamfactoryurl": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/dreamfactory-mcp/overview" + }, + "duckduckgo": { + "title": "DuckDuckGo", + "description": "A Model Context Protocol (MCP) server that provides web search capabilities through DuckDuckGo, with additional features for content fetching and parsing.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/duckduckgo/overview" + }, + "dynatrace": { + "title": "Dynatrace MCP Server", + "description": "This MCP Server allows interaction with the Dynatrace observability platform, brining real-time observability data directly into your development workflow.", + "required": [ + "oauthClientId", + "oauthClientSecret", + "url" + ], + "additionalProperties": false, + "properties": { + "oauthClientId": { + "type": "string" + }, + "oauthClientSecret": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/dynatrace-mcp-server/overview" + }, + "e2b": { + "title": "E2B", + "description": "Giving Claude ability to run code with E2B via MCP (Model Context Protocol).", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/e2b/overview" + }, + "edubase": { + "title": "EduBase", + "description": "The EduBase MCP server enables Claude and other LLMs to interact with EduBase's comprehensive e-learning platform through the Model Context Protocol (MCP).", + "required": [ + "app", + "url" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "app": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/edubase/overview" + }, + "effect": { + "title": "Effect MCP", + "description": "Tools and resources for writing Effect code in Typescript.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/effect-mcp/overview" + }, + "elasticsearch": { + "title": "Elasticsearch", + "description": "Interact with your Elasticsearch indices through natural language conversations.", + "required": [ + "url" + ], + "additionalProperties": false, + "properties": { + "esApiKey": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/elasticsearch/overview" + }, + "elevenlabs": { + "title": "Elevenlabs MCP", + "description": "Official ElevenLabs Model Context Protocol (MCP) server that enables interaction with powerful Text to Speech and audio processing APIs.", + "required": [ + "data" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/elevenlabs/overview" + }, + "everart": { + "title": "EverArt (Archived)", + "description": "Image generation server using EverArt's API.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/everart/overview" + }, + "exa": { + "title": "Exa", + "description": "Exa MCP for web search and web crawling!.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/exa/overview" + }, + "explorium": { + "title": "Explorium B2B Data", + "description": "Discover companies, contacts, and business insights—powered by dozens of trusted external data sources.", + "required": [ + "apiAccessToken" + ], + "additionalProperties": false, + "properties": { + "apiAccessToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/explorium/overview" + }, + "fetch": { + "title": "Fetch (Reference)", + "description": "Fetches a URL from the internet and extracts its contents as markdown.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/fetch/overview" + }, + "fibery": { + "title": "Fibery", + "description": "Interact with your Fibery workspace.", + "required": [ + "apiToken", + "host" + ], + "additionalProperties": false, + "properties": { + "apiToken": { + "type": "string" + }, + "host": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/fibery/overview" + }, + "filesystem": { + "title": "Filesystem (Reference)", + "description": "Local filesystem access with configurable allowed paths.", + "required": [ + "paths" + ], + "additionalProperties": false, + "properties": { + "paths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/filesystem/overview" + }, + "findADomain": { + "title": "Find-A-Domain", + "description": "Tools for finding domain names.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/find-a-domain/overview" + }, + "firecrawl": { + "title": "Firecrawl", + "description": "🔥 Official Firecrawl MCP Server - Adds powerful web scraping and search to Cursor, Claude and any other LLM clients.", + "required": [ + "apiKey", + "creditCriticalThreshold", + "creditWarningThreshold", + "retryBackoffFactor", + "retryDelay", + "retryMax", + "retryMaxDelay", + "url" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "creditCriticalThreshold": { + "type": "integer" + }, + "creditWarningThreshold": { + "type": "integer" + }, + "retryBackoffFactor": { + "type": "integer" + }, + "retryDelay": { + "type": "integer" + }, + "retryMax": { + "type": "integer" + }, + "retryMaxDelay": { + "type": "integer" + }, + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/firecrawl/overview" + }, + "firewalla": { + "title": "Firewalla MCP Server", + "description": "Real-time network monitoring, security analysis, and firewall management through 28 specialized tools. Access security alerts, network flows, device status, and firewall rules directly from your Firewalla device.", + "required": [ + "boxId", + "firewallaMspToken", + "mspId" + ], + "additionalProperties": false, + "properties": { + "boxId": { + "description": "Your Firewalla Box Global ID", + "type": "string" + }, + "firewallaMspToken": { + "type": "string" + }, + "mspId": { + "description": "Your Firewalla MSP domain (e.g., yourdomain.firewalla.net)", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/firewalla-mcp-server/overview" + }, + "flexprice": { + "title": "FlexPrice", + "description": "Official flexprice MCP Server.", + "required": [ + "apiKey", + "baseUrl" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "baseUrl": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/flexprice/overview" + }, + "git": { + "title": "Git (Reference)", + "description": "Git repository interaction and automation.", + "required": [ + "paths" + ], + "additionalProperties": false, + "properties": { + "paths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/git/overview" + }, + "github": { + "title": "GitHub (Archived)", + "description": "Tools for interacting with the GitHub API, enabling file operations, repository management, search functionality, and more.", + "required": [ + "personalAccessToken" + ], + "additionalProperties": false, + "properties": { + "personalAccessToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/github/overview" + }, + "githubChat": { + "title": "GitHub Chat", + "description": "A Model Context Protocol (MCP) for analyzing and querying GitHub repositories using the GitHub Chat API.", + "required": [ + "githubApiKey" + ], + "additionalProperties": false, + "properties": { + "githubApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/github-chat/overview" + }, + "githubOfficial": { + "title": "GitHub Official", + "description": "Official GitHub MCP Server, by GitHub. Provides seamless integration with GitHub APIs, enabling advanced automation and interaction capabilities for developers and tools.", + "required": [ + "githubPersonalAccessToken" + ], + "additionalProperties": false, + "properties": { + "githubPersonalAccessToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/github-official/overview" + }, + "gitlab": { + "title": "GitLab (Archived)", + "description": "MCP Server for the GitLab API, enabling project management, file operations, and more.", + "required": [ + "personalAccessToken", + "url" + ], + "additionalProperties": false, + "properties": { + "personalAccessToken": { + "type": "string" + }, + "url": { + "description": "api url - optional for self-hosted instances", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/gitlab/overview" + }, + "gitmcp": { + "title": "GitMCP", + "description": "Tools for interacting with Git repositories.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/gitmcp/overview" + }, + "glif": { + "title": "glif.app", + "description": "Easily run glif.app AI workflows inside your LLM: image generators, memes, selfies, and more. Glif supports all major multimedia AI models inside one app.", + "required": [ + "apiToken", + "ids", + "ignoredSaved" + ], + "additionalProperties": false, + "properties": { + "apiToken": { + "type": "string" + }, + "ids": { + "type": "string" + }, + "ignoredSaved": { + "type": "boolean" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/glif/overview" + }, + "gmail": { + "title": "Gmail MCP Server", + "description": "A Model Context Protocol server for Gmail operations using IMAP/SMTP with app password authentication. Supports listing messages, searching emails, and sending messages. To create your app password, visit your Google Account settings under Security \u003e App Passwords. Or visit the link https://myaccount.google.com/apppasswords.", + "required": [ + "emailAddress" + ], + "additionalProperties": false, + "properties": { + "emailAddress": { + "description": "Your Gmail email address", + "type": "string" + }, + "emailPassword": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/gmail-mcp/overview" + }, + "googleMaps": { + "title": "Google Maps (Archived)", + "description": "Tools for interacting with the Google Maps API.", + "required": [ + "googleMapsApiKey" + ], + "additionalProperties": false, + "properties": { + "googleMapsApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/google-maps/overview" + }, + "googleMapsComprehensive": { + "title": "Google Maps Comprehensive MCP", + "description": "Complete Google Maps integration with 8 tools including geocoding, places search, directions, elevation data, and more using Google's latest APIs.", + "required": [ + "googleMapsApiKey" + ], + "additionalProperties": false, + "properties": { + "googleMapsApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/google-maps-comprehensive/overview" + }, + "grafana": { + "title": "Grafana", + "description": "MCP server for Grafana.", + "required": [ + "apiKey", + "url" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/grafana/overview" + }, + "gyazo": { + "title": "Gyazo", + "description": "Official Model Context Protocol server for Gyazo.", + "required": [ + "accessToken" + ], + "additionalProperties": false, + "properties": { + "accessToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/gyazo/overview" + }, + "hackernews": { + "title": "Hackernews mcp", + "description": "A Model Context Protocol (MCP) server that provides access to Hacker News stories, comments, and user data, with support for search and content retrieval.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mcp-hackernews/overview" + }, + "hackle": { + "title": "Hackle", + "description": "Model Context Protocol server for Hackle.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hackle/overview" + }, + "handwritingOcr": { + "title": "Handwriting OCR", + "description": "Model Context Protocol (MCP) Server for Handwriting OCR.", + "required": [ + "apiToken" + ], + "additionalProperties": false, + "properties": { + "apiToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/handwriting-ocr/overview" + }, + "hdx": { + "title": "Humanitarian Data Exchange MCP Server", + "description": "HDX MCP Server provides access to humanitarian data through the Humanitarian Data Exchange (HDX) API - https://data.humdata.org/hapi. This server offers 33 specialized tools for retrieving humanitarian information including affected populations (refugees, IDPs, returnees), baseline demographics, food security indicators, conflict data, funding information, and operational presence across hundreds of countries and territories. See repository for instructions on getting a free HDX_APP_INDENTIFIER for access.", + "required": [ + "appIdentifier" + ], + "additionalProperties": false, + "properties": { + "appIdentifier": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hdx/overview" + }, + "heroku": { + "title": "Heroku", + "description": "Heroku Platform MCP Server using the Heroku CLI.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/heroku/overview" + }, + "hostinger": { + "title": "Hostinger API MCP Server", + "description": "Interact with Hostinger services over the Hostinger API.", + "required": [ + "apitoken" + ], + "additionalProperties": false, + "properties": { + "apitoken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hostinger-mcp-server/overview" + }, + "hoverfly": { + "title": "Hoverfly MCP Server", + "description": "A Model Context Protocol (MCP) server that exposes Hoverfly as a programmable tool for AI assistants like Cursor, Claude, GitHub Copilot, and others supporting MCP. It enables dynamic mocking of third-party APIs to unblock development, automate testing, and simulate unavailable services during integration.", + "required": [ + "data" + ], + "additionalProperties": false, + "properties": { + "data": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hoverfly-mcp-server/overview" + }, + "hubspot": { + "title": "HubSpot", + "description": "Unite marketing, sales, and customer service with AI-powered automation, lead management, and comprehensive analytics.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hubspot/overview" + }, + "huggingFace": { + "title": "Hugging Face", + "description": "Tools for interacting with Hugging Face models, datasets, research papers, and more.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hugging-face/overview" + }, + "hummingbot": { + "title": "Hummingbot MCP: Trading Agent", + "description": "Hummingbot MCP is an open-source toolset that lets you control and monitor your Hummingbot trading bots through AI-powered commands and automation.", + "required": [ + "apiUrl" + ], + "additionalProperties": false, + "properties": { + "apiUrl": { + "type": "string" + }, + "hummingbotApiPassword": { + "type": "string" + }, + "hummingbotApiUsername": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hummingbot-mcp/overview" + }, + "husqvarnaAutomower": { + "title": "Husqvarna Automower", + "description": "MCP Server for huqsvarna automower.", + "required": [ + "clientId", + "husqvarnaClientSecret" + ], + "additionalProperties": false, + "properties": { + "clientId": { + "type": "string" + }, + "husqvarnaClientSecret": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/husqvarna-automower/overview" + }, + "hyperbrowser": { + "title": "Hyperbrowser", + "description": "A MCP server implementation for hyperbrowser.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hyperbrowser/overview" + }, + "hyperspell": { + "title": "Hyperspell", + "description": "Hyperspell MCP Server.", + "required": [ + "collection", + "token", + "useResources" + ], + "additionalProperties": false, + "properties": { + "collection": { + "type": "string" + }, + "token": { + "type": "string" + }, + "useResources": { + "type": "boolean" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hyperspell/overview" + }, + "iaptic": { + "title": "Iaptic", + "description": "Model Context Protocol server for interacting with iaptic.", + "required": [ + "appName" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "appName": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/iaptic/overview" + }, + "inspektorGadget": { + "title": "Inspektor Gadget", + "description": "AI interface to troubleshoot and observe Kubernetes/Container workloads.", + "required": [ + "kubeconfig" + ], + "additionalProperties": false, + "properties": { + "gadgetImages": { + "description": "Comma-separated list of gadget images (trace_dns, trace_tcp, etc) to use, allowing control over which gadgets are available as MCP tools", + "type": "string" + }, + "kubeconfig": { + "description": "Path to the kubeconfig file for accessing Kubernetes clusters", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/inspektor-gadget/overview" + }, + "javadocs": { + "title": "Javadocs", + "description": "Access to Java, Kotlin, and Scala library documentation.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/javadocs/overview" + }, + "jetbrains": { + "title": "JetBrains", + "description": "A model context protocol server to work with JetBrains IDEs: IntelliJ, PyCharm, WebStorm, etc. Also, works with Android Studio.", + "required": [ + "port" + ], + "additionalProperties": false, + "properties": { + "port": { + "type": "integer" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/jetbrains/overview" + }, + "kafkaSchemaReg": { + "title": "Kafka Schema Registry MCP", + "description": "Comprehensive MCP server for Kafka Schema Registry operations. Features multi-registry support, schema contexts, migration tools, OAuth authentication, and 57+ tools for complete schema management. Supports SLIM_MODE for optimal performance.", + "required": [ + "registryUrl" + ], + "additionalProperties": false, + "properties": { + "registryUrl": { + "description": "Schema Registry URL", + "type": "string" + }, + "schemaRegistryPassword": { + "type": "string" + }, + "schemaRegistryUser": { + "type": "string" + }, + "slimMode": { + "description": "Enable SLIM_MODE for better performance", + "type": "string" + }, + "viewonly": { + "description": "Enable read-only mode", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/kafka-schema-reg-mcp/overview" + }, + "kagisearch": { + "title": "Kagi search", + "description": "The Official Model Context Protocol (MCP) server for Kagi search \u0026 other tools.", + "required": [ + "engine", + "kagiApiKey" + ], + "additionalProperties": false, + "properties": { + "engine": { + "type": "string" + }, + "kagiApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/kagisearch/overview" + }, + "keboola": { + "title": "Keboola MCP Server", + "description": "Keboola MCP Server is an open-source bridge between your Keboola project and modern AI tools.", + "required": [ + "kbcStorageToken", + "kbcWorkspaceSchema" + ], + "additionalProperties": false, + "properties": { + "kbcStorageToken": { + "type": "string" + }, + "kbcWorkspaceSchema": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/keboola-mcp/overview" + }, + "kong": { + "title": "Kong Konnect", + "description": "A Model Context Protocol (MCP) server for interacting with Kong Konnect APIs, allowing AI assistants to query and analyze Kong Gateway configurations, traffic, and analytics.", + "required": [ + "konnectAccessToken", + "region" + ], + "additionalProperties": false, + "properties": { + "konnectAccessToken": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/kong/overview" + }, + "kubectl": { + "title": "Kubectl MCP Server", + "description": "MCP Server that enables AI assistants to interact with Kubernetes clusters via kubectl operations.", + "required": [ + "kubeconfig" + ], + "additionalProperties": false, + "properties": { + "kubeconfig": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/kubectl-mcp-server/overview" + }, + "kubernetes": { + "title": "Kubernetes", + "description": "Connect to a Kubernetes cluster and manage it.", + "required": [ + "configPath" + ], + "additionalProperties": false, + "properties": { + "configPath": { + "description": "the path to the host .kube/config", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/kubernetes/overview" + }, + "lara": { + "title": "Lara Translate", + "description": "Connect to Lara Translate API, enabling powerful translation capabilities with support for language detection and context-aware translations.", + "required": [ + "keyId" + ], + "additionalProperties": false, + "properties": { + "accessKeySecret": { + "type": "string" + }, + "keyId": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/lara/overview" + }, + "line": { + "title": "LINE", + "description": "MCP server that integrates the LINE Messaging API to connect an AI Agent to the LINE Official Account.", + "required": [ + "userId" + ], + "additionalProperties": false, + "properties": { + "channelAccessToken": { + "type": "string" + }, + "userId": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/line/overview" + }, + "linkedin": { + "title": "LinkedIn MCP Server", + "description": "This MCP server allows Claude and other AI assistants to access your LinkedIn. Scrape LinkedIn profiles and companies, get your recommended jobs, and perform job searches. Set your li_at LinkedIn cookie to use this server.", + "required": [ + "linkedinCookie", + "userAgent" + ], + "additionalProperties": false, + "properties": { + "linkedinCookie": { + "type": "string" + }, + "userAgent": { + "description": "Custom user agent string (optional, helps avoid detection and cookie login issues)", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/linkedin-mcp-server/overview" + }, + "llmtxt": { + "title": "LLM Text", + "description": "Discovers and retrieves llms.txt from websites.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/llmtxt/overview" + }, + "maestro": { + "title": "Maestro MCP Server", + "description": "A Model Context Protocol (MCP) server exposing Bitcoin blockchain data through the Maestro API platform. Provides tools to explore blocks, transactions, addresses, inscriptions, runes, and other metaprotocol data.", + "required": [ + "apiKeyApiKey" + ], + "additionalProperties": false, + "properties": { + "apiKeyApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/maestro-mcp-server/overview" + }, + "manifold": { + "title": "Manifold", + "description": "Tools for accessing the Manifold Markets online prediction market platform.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/manifold/overview" + }, + "mapbox": { + "title": "Mapbox MCP Server", + "description": "Transform any AI agent into a geospatially-aware system with Mapbox APIs. Provides geocoding, POI search, routing, travel time matrices, isochrones, and static map generation.", + "required": [ + "accessToken" + ], + "additionalProperties": false, + "properties": { + "accessToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mapbox/overview" + }, + "mapboxDevkit": { + "title": "Mapbox Developer MCP Server", + "description": "Direct access to Mapbox developer APIs for AI assistants. Enables style management, token management, GeoJSON preview, and other developer tools for building Mapbox applications.", + "required": [ + "mapboxAccessToken" + ], + "additionalProperties": false, + "properties": { + "mapboxAccessToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mapbox-devkit/overview" + }, + "markdownify": { + "title": "Markdownify", + "description": "A Model Context Protocol server for converting almost anything to Markdown.", + "required": [ + "paths" + ], + "additionalProperties": false, + "properties": { + "paths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/markdownify/overview" + }, + "markitdown": { + "title": "Markitdown", + "description": "A lightweight MCP server for calling MarkItDown.", + "required": [ + "paths" + ], + "additionalProperties": false, + "properties": { + "paths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/markitdown/overview" + }, + "mavenTools": { + "title": "Maven Tools MCP Server", + "description": "JVM dependency intelligence for any build tool using Maven Central Repository. Includes Context7 integration for upgrade documentation and guidance.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/maven-tools-mcp/overview" + }, + "memory": { + "title": "Memory (Reference)", + "description": "Knowledge graph-based persistent memory system.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/memory/overview" + }, + "mercadoLibre": { + "title": "Mercado Libre", + "description": "Provides access to Mercado Libre E-Commerce API.", + "required": [ + "mercadoLibreApiKey" + ], + "additionalProperties": false, + "properties": { + "mercadoLibreApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mercado-libre/overview" + }, + "mercadoPago": { + "title": "Mercado Pago", + "description": "Provides access to Mercado Pago Marketplace API.", + "required": [ + "mercadoPagoApiKey" + ], + "additionalProperties": false, + "properties": { + "mercadoPagoApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mercado-pago/overview" + }, + "metabase": { + "title": "Metabase MCP", + "description": "A comprehensive MCP server for Metabase with 70+ tools.", + "required": [ + "apiKey", + "metabaseurl", + "metabaseusername", + "password" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "metabaseurl": { + "type": "string" + }, + "metabaseusername": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/metabase/overview" + }, + "minecraftWiki": { + "title": "Minecraft Wiki", + "description": "A MCP Server for browsing the official Minecraft Wiki!.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/minecraft-wiki/overview" + }, + "mongodb": { + "title": "MongoDB", + "description": "A Model Context Protocol server to connect to MongoDB databases and MongoDB Atlas Clusters.", + "required": [ + "mdbMcpConnectionString" + ], + "additionalProperties": false, + "properties": { + "mdbMcpConnectionString": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mongodb/overview" + }, + "multiversxMx": { + "title": "MultiversX", + "description": "MCP Server for MultiversX.", + "required": [ + "network", + "wallet" + ], + "additionalProperties": false, + "properties": { + "network": { + "type": "string" + }, + "wallet": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/multiversx-mx/overview" + }, + "nasdaqDataLink": { + "title": "Nasdaq Data Link", + "description": "MCP server to interact with the data feeds provided by the Nasdaq Data Link. Developed by the community and maintained by Stefano Amorelli.", + "required": [ + "nasdaqDataLinkApiKey" + ], + "additionalProperties": false, + "properties": { + "nasdaqDataLinkApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/nasdaq-data-link/overview" + }, + "needle": { + "title": "Needle", + "description": "Production-ready RAG service to search and retrieve data from your documents.", + "required": [ + "needleApiKey" + ], + "additionalProperties": false, + "properties": { + "needleApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/needle-mcp/overview" + }, + "neo4jCloudAuraApi": { + "title": "Neo4j Cloud Aura Api", + "description": "Manage Neo4j Aura database instances through the Neo4j Aura API.", + "required": [ + "clientId" + ], + "additionalProperties": false, + "properties": { + "clientId": { + "type": "string" + }, + "neo4jAuraClientSecret": { + "type": "string" + }, + "serverAllowOrigins": { + "type": "string" + }, + "serverAllowedHosts": { + "type": "string" + }, + "serverHost": { + "type": "string" + }, + "serverPath": { + "type": "string" + }, + "serverPort": { + "type": "string" + }, + "transport": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/neo4j-cloud-aura-api/overview" + }, + "neo4jCypher": { + "title": "Neo4j Cypher", + "description": "Interact with Neo4j using Cypher graph queries.", + "required": [ + "url", + "username" + ], + "additionalProperties": false, + "properties": { + "database": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "neo4jPassword": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "readTimeout": { + "type": "string" + }, + "responseTokenLimit": { + "type": "string" + }, + "serverAllowOrigins": { + "type": "string" + }, + "serverAllowedHosts": { + "type": "string" + }, + "serverHost": { + "type": "string" + }, + "serverPath": { + "type": "string" + }, + "serverPort": { + "type": "string" + }, + "transport": { + "type": "string" + }, + "url": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/neo4j-cypher/overview" + }, + "neo4jDataModeling": { + "title": "Neo4j Data Modeling", + "description": "MCP server that assists in creating, validating and visualizing graph data models.", + "required": [ + "serverAllowOrigins", + "serverAllowedHosts", + "serverHost", + "serverPath", + "serverPort", + "transport" + ], + "additionalProperties": false, + "properties": { + "serverAllowOrigins": { + "type": "string" + }, + "serverAllowedHosts": { + "type": "string" + }, + "serverHost": { + "type": "string" + }, + "serverPath": { + "type": "string" + }, + "serverPort": { + "type": "string" + }, + "transport": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/neo4j-data-modeling/overview" + }, + "neo4jMemory": { + "title": "Neo4j Memory", + "description": "Provide persistent memory capabilities through Neo4j graph database integration.", + "required": [ + "url", + "username" + ], + "additionalProperties": false, + "properties": { + "database": { + "type": "string" + }, + "neo4jPassword": { + "type": "string" + }, + "serverAllowOrigins": { + "type": "string" + }, + "serverAllowedHosts": { + "type": "string" + }, + "serverHost": { + "type": "string" + }, + "serverPath": { + "type": "string" + }, + "serverPort": { + "type": "string" + }, + "transport": { + "type": "string" + }, + "url": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/neo4j-memory/overview" + }, + "neon": { + "title": "Neon", + "description": "MCP server for interacting with Neon Management API and databases.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/neon/overview" + }, + "nodeCodeSandbox": { + "title": "Node.js Sandbox", + "description": "A Node.js–based Model Context Protocol server that spins up disposable Docker containers to execute arbitrary JavaScript.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/node-code-sandbox/overview" + }, + "notion": { + "title": "Notion", + "description": "Official Notion MCP Server.", + "required": [ + "internalIntegrationToken" + ], + "additionalProperties": false, + "properties": { + "internalIntegrationToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/notion/overview" + }, + "novita": { + "title": "Novita", + "description": "Seamless interaction with Novita AI platform resources.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/novita/overview" + }, + "npmSentinel": { + "title": "NPM Sentinel", + "description": "MCP server that enables intelligent NPM package analysis powered by AI.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/npm-sentinel/overview" + }, + "obsidian": { + "title": "Obsidian", + "description": "MCP server that interacts with Obsidian via the Obsidian rest API community plugin.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/obsidian/overview" + }, + "oktaMcpFctr": { + "title": "Okta MCP Server", + "description": "Secure Okta identity and access management via Model Context Protocol (MCP). Access Okta users, groups, applications, logs, and policies through AI assistants with enterprise-grade security.", + "required": [ + "clientOrgurl" + ], + "additionalProperties": false, + "properties": { + "clientOrgurl": { + "description": "Okta organization URL (e.g., https://dev-123456.okta.com)", + "type": "string" + }, + "concurrentLimit": { + "description": "Maximum concurrent requests to Okta API", + "type": "string" + }, + "logLevel": { + "description": "Logging level for server output", + "type": "string" + }, + "oktaApiToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/okta-mcp-fctr/overview" + }, + "omi": { + "title": "omi-mcp", + "description": "A Model Context Protocol server for Omi interaction and automation. This server provides tools to read, search, and manipulate Memories and Conversations.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/omi/overview" + }, + "onlyofficeDocspace": { + "title": "ONLYOFFICE DocSpace", + "description": "ONLYOFFICE DocSpace is a room-based collaborative platform which allows organizing a clear file structure depending on users' needs or project goals.", + "required": [ + "baseUrl", + "docspaceApiKey", + "docspaceAuthToken", + "docspacePassword", + "docspaceUsername", + "dynamic", + "origin", + "toolsets", + "userAgent" + ], + "additionalProperties": false, + "properties": { + "baseUrl": { + "type": "string" + }, + "docspaceApiKey": { + "type": "string" + }, + "docspaceAuthToken": { + "type": "string" + }, + "docspacePassword": { + "type": "string" + }, + "docspaceUsername": { + "type": "string" + }, + "dynamic": { + "type": "boolean" + }, + "origin": { + "type": "string" + }, + "toolsets": { + "type": "string" + }, + "userAgent": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/onlyoffice-docspace/overview" + }, + "openapi": { + "title": "OpenAPI Toolkit for MCP", + "description": "Fetch, validate, and generate code or curl from any OpenAPI or Swagger spec - all from a single URL.", + "required": [ + "mode" + ], + "additionalProperties": false, + "properties": { + "mode": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openapi/overview" + }, + "openapiSchema": { + "title": "OpenAPI Schema", + "description": "OpenAPI Schema Model Context Protocol Server.", + "required": [ + "SchemaPath" + ], + "additionalProperties": false, + "properties": { + "SchemaPath": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openapi-schema/overview" + }, + "openbnbAirbnb": { + "title": "Airbnb Search", + "description": "MCP Server for searching Airbnb and get listing details.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openbnb-airbnb/overview" + }, + "openmesh": { + "title": "OpenMesh", + "description": "Discover and connect to a curated marketplace of MCP servers for extending AI agent capabilities.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openmesh/overview" + }, + "openweather": { + "title": "Openweather", + "description": "A simple MCP service that provides current weather and 5-day forecast using the free OpenWeatherMap API.", + "required": [ + "owmApiKey" + ], + "additionalProperties": false, + "properties": { + "owmApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openweather/overview" + }, + "openzeppelinCairo": { + "title": "OpenZeppelin Cairo Contracts", + "description": "Access to OpenZeppelin Cairo Contracts.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openzeppelin-cairo/overview" + }, + "openzeppelinSolidity": { + "title": "OpenZeppelin Solidity Contracts", + "description": "Access to OpenZeppelin Solidity Contracts.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openzeppelin-solidity/overview" + }, + "openzeppelinStellar": { + "title": "OpenZeppelin Stellar Contracts", + "description": "Access to OpenZeppelin Stellar Contracts.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openzeppelin-stellar/overview" + }, + "openzeppelinStylus": { + "title": "OpenZeppelin Stylus Contracts", + "description": "Access to OpenZeppelin Stylus Contracts.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openzeppelin-stylus/overview" + }, + "opik": { + "title": "Opik", + "description": "Model Context Protocol (MCP) implementation for Opik enabling seamless IDE integration and unified access to prompts, projects, traces, and metrics.", + "required": [ + "apiBaseUrl", + "apiKey", + "workspaceName" + ], + "additionalProperties": false, + "properties": { + "apiBaseUrl": { + "type": "string" + }, + "apiKey": { + "type": "string" + }, + "workspaceName": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/opik/overview" + }, + "opine": { + "title": "Opine MCP Server", + "description": "A Model Context Protocol (MCP) server for querying deals and evaluations from the Opine CRM API.", + "required": [ + "opineApiKey" + ], + "additionalProperties": false, + "properties": { + "opineApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/opine-mcp-server/overview" + }, + "oracle": { + "title": "Oracle Database MCP Server", + "description": "Connect to Oracle databases via MCP, providing secure read-only access with support for schema exploration, query execution, and metadata inspection.", + "required": [ + "oracleConnectionString", + "oracleUser", + "password" + ], + "additionalProperties": false, + "properties": { + "oracleConnectionString": { + "type": "string" + }, + "oracleUser": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/oracle/overview" + }, + "ospMarketingTools": { + "title": "OSP Marketing Tools", + "description": "A Model Context Protocol (MCP) server that empowers LLMs to use some of Open Srategy Partners' core writing and product marketing techniques.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/osp_marketing_tools/overview" + }, + "oxylabs": { + "title": "Oxylabs", + "description": "A Model Context Protocol (MCP) server that enables AI assistants like Claude to seamlessly access web data through Oxylabs' powerful web scraping technology.", + "required": [ + "username" + ], + "additionalProperties": false, + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/oxylabs/overview" + }, + "paperSearch": { + "title": "Paper Search", + "description": "A MCP for searching and downloading academic papers from multiple sources like arXiv, PubMed, bioRxiv, etc.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/paper-search/overview" + }, + "perplexityAsk": { + "title": "Perplexity", + "description": "Connector for Perplexity API, to enable real-time, web-wide research.", + "required": [ + "perplexityApiKey" + ], + "additionalProperties": false, + "properties": { + "perplexityApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/perplexity-ask/overview" + }, + "pia": { + "title": "Program Integrity Alliance", + "description": "An MCP server to help make U.S. Government open datasets AI-friendly.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/pia/overview" + }, + "pinecone": { + "title": "Pinecone Assistant", + "description": "Pinecone Assistant MCP server.", + "required": [ + "apiKey", + "assistantHost" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "assistantHost": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/pinecone/overview" + }, + "playwright": { + "title": "ExecuteAutomation Playwright MCP", + "description": "Playwright Model Context Protocol Server - Tool to automate Browsers and APIs in Claude Desktop, Cline, Cursor IDE and More 🔌.", + "required": [ + "data" + ], + "additionalProperties": false, + "properties": { + "data": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/playwright-mcp-server/overview" + }, + "pluggedinMcpProxy": { + "title": "Plugged.in MCP Proxy", + "description": "A unified MCP proxy that aggregates multiple MCP servers into one interface, enabling seamless tool discovery and management across all your AI interactions. Manage all your MCP servers from a single connection point with RAG capabilities and real-time notifications.", + "required": [ + "pluggedinApiBaseUrl", + "pluggedinApiKey" + ], + "additionalProperties": false, + "properties": { + "pluggedinApiBaseUrl": { + "description": "Base URL for the Plugged.in API (optional, defaults to https://plugged.in for cloud or http://localhost:12005 for self-hosted)", + "type": "string" + }, + "pluggedinApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/pluggedin-mcp-proxy/overview" + }, + "polarSignals": { + "title": "Polar Signals", + "description": "MCP server for Polar Signals Cloud continuous profiling platform, enabling AI assistants to analyze CPU performance, memory usage, and identify optimization opportunities in production systems.", + "required": [ + "polarSignalsApiKey" + ], + "additionalProperties": false, + "properties": { + "polarSignalsApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/polar-signals/overview" + }, + "pomodash": { + "title": "PomoDash", + "description": "Connect your AI assistant to PomoDash for seamless task and project management.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/pomodash/overview" + }, + "postgres": { + "title": "PostgreSQL readonly (Archived)", + "description": "Connect with read-only access to PostgreSQL databases. This server enables LLMs to inspect database schemas and execute read-only queries.", + "required": [ + "url" + ], + "additionalProperties": false, + "properties": { + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/postgres/overview" + }, + "postman": { + "title": "Postman MCP server", + "description": "Postman's MCP server connects AI agents, assistants, and chatbots directly to your APIs on Postman. Use natural language to prompt AI to automate work across your Postman collections, environments, workspaces, and more.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/postman/overview" + }, + "prefEditor": { + "title": "Pref Editor", + "description": "Pref Editor is a tool for viewing and editing Android app preferences during development.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/pref-editor/overview" + }, + "prometheus": { + "title": "Prometheus", + "description": "A Model Context Protocol (MCP) server that enables AI assistants to query and analyze Prometheus metrics through standardized interfaces. Connect to your Prometheus instance to retrieve metrics, perform queries, and gain insights into your system's performance and health.", + "required": [ + "prometheusUrl" + ], + "additionalProperties": false, + "properties": { + "prometheusUrl": { + "description": "The URL of your Prometheus server", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/prometheus/overview" + }, + "puppeteer": { + "title": "Puppeteer (Archived)", + "description": "Browser automation and web scraping using Puppeteer.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/puppeteer/overview" + }, + "pythonRefactoring": { + "title": "Python Refactoring Assistant", + "description": "Educational Python refactoring assistant that provides guided suggestions for AI assistants. Features: • Step-by-step refactoring instructions without modifying code • Comprehensive code analysis using professional tools (Rope, Radon, Vulture, Jedi, LibCST, Pyrefly) • Educational approach teaching refactoring patterns through guided practice • Support for both guide-only and apply-changes modes • Identifies long functions, high complexity, dead code, and type issues • Provides precise line numbers and specific refactoring instructions • Compatible with all AI assistants (Claude, GPT, Cursor, Continue, etc.) Perfect for developers learning refactoring patterns while maintaining full control over code changes. Acts as a refactoring mentor rather than an automated code modifier.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mcp-python-refactoring/overview" + }, + "quantconnect": { + "title": "QuantConnect MCP Server", + "description": "The QuantConnect MCP Server is a bridge for AIs (such as Claude and OpenAI o3 Pro) to interact with our cloud platform. When equipped with our MCP, the AI can perform tasks on your behalf through our API such as updating projects, writing strategies, backtesting, and deploying strategies to production live-trading.", + "required": [ + "agentname", + "quantconnectapitoken", + "quantconnectuserid" + ], + "additionalProperties": false, + "properties": { + "agentname": { + "type": "string" + }, + "quantconnectapitoken": { + "type": "string" + }, + "quantconnectuserid": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/quantconnect/overview" + }, + "ramparts": { + "title": "Ramparts MCP Security Scanner", + "description": "A comprehensive security scanner for MCP servers with YARA rules and static analysis capabilities.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/ramparts/overview" + }, + "razorpay": { + "title": "Razorpay", + "description": "Razorpay's Official MCP Server.", + "required": [ + "keyId" + ], + "additionalProperties": false, + "properties": { + "keyId": { + "type": "string" + }, + "keySecret": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/razorpay/overview" + }, + "reddit": { + "title": "Mcp reddit", + "description": "A comprehensive Model Context Protocol (MCP) server for Reddit integration. This server enables AI agents to interact with Reddit programmatically through a standardized interface.", + "required": [ + "redditClientId", + "redditClientSecret", + "redditPassword", + "username" + ], + "additionalProperties": false, + "properties": { + "redditClientId": { + "type": "string" + }, + "redditClientSecret": { + "type": "string" + }, + "redditPassword": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mcp-reddit/overview" + }, + "redis": { + "title": "Redis", + "description": "Access to Redis database operations.", + "required": [ + "caCerts", + "caPath", + "certReqs", + "clusterMode", + "host", + "port", + "pwd", + "ssl", + "sslCertfile", + "sslKeyfile", + "username" + ], + "additionalProperties": false, + "properties": { + "caCerts": { + "type": "string" + }, + "caPath": { + "type": "string" + }, + "certReqs": { + "type": "string" + }, + "clusterMode": { + "type": "boolean" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "pwd": { + "type": "string" + }, + "ssl": { + "type": "boolean" + }, + "sslCertfile": { + "type": "string" + }, + "sslKeyfile": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/redis/overview" + }, + "redisCloud": { + "title": "Redis Cloud", + "description": "MCP Server for Redis Cloud's API, allowing you to manage your Redis Cloud resources using natural language.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "secretKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/redis-cloud/overview" + }, + "ref": { + "title": "Ref - up-to-date docs", + "description": "Ref powerful search tool connets your coding tools with documentation context. It includes an up-to-date index of public documentation and it can ingest your private documentation (eg. GitHub repos, PDFs) as well.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/ref/overview" + }, + "remote": { + "title": "Remote MCP", + "description": "Tools for finding remote MCP servers.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/remote-mcp/overview" + }, + "render": { + "title": "Render", + "description": "Interact with your Render resources via LLMs.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/render/overview" + }, + "resend": { + "title": "Send emails", + "description": "Send emails directly from Cursor with this email sending MCP server.", + "required": [ + "replyTo", + "sender" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "replyTo": { + "description": "comma separated list of reply to email addresses", + "type": "string" + }, + "sender": { + "description": "sender email address", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/resend/overview" + }, + "risken": { + "title": "RISKEN", + "description": "RISKEN's official MCP Server.", + "required": [ + "accessToken", + "url" + ], + "additionalProperties": false, + "properties": { + "accessToken": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/risken/overview" + }, + "root": { + "title": "Root.io Vulnerability Remediation MCP", + "description": "MCP server that provides container image vulnerability scanning and remediation capabilities through Root.io.", + "required": [ + "apiAccessToken" + ], + "additionalProperties": false, + "properties": { + "apiAccessToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/root/overview" + }, + "ros2": { + "title": "WiseVision ROS2 MCP Server", + "description": "Python server implementing Model Context Protocol (MCP) for ROS2.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/ros2/overview" + }, + "rube": { + "title": "Rube", + "description": "Access to Rube's catalog of remote MCP servers.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/rube/overview" + }, + "rustMcpFilesystem": { + "title": "Blazing-fast, asynchronous MCP server for seamless filesystem operations.", + "description": "The Rust MCP Filesystem is a high-performance, asynchronous, and lightweight Model Context Protocol (MCP) server built in Rust for secure and efficient filesystem operations. Designed with security in mind, it operates in read-only mode by default and restricts clients from updating allowed directories via MCP Roots unless explicitly enabled, ensuring robust protection against unauthorized access. Leveraging asynchronous I/O, it delivers blazingly fast performance with a minimal resource footprint. Optimized for token efficiency, the Rust MCP Filesystem enables large language models (LLMs) to precisely target searches and edits within specific sections of large files and restrict operations by file size range, making it ideal for efficient file exploration, automation, and system integration.", + "required": [ + "allowWrite", + "allowedDirectories", + "enableRoots" + ], + "additionalProperties": false, + "properties": { + "allowWrite": { + "description": "Enable read/write mode. If false, the app operates in read-only mode.", + "type": "boolean" + }, + "allowedDirectories": { + "description": "List of directories that rust-mcp-filesystem can access.", + "items": { + "type": "string" + }, + "type": "array" + }, + "enableRoots": { + "description": "Enable dynamic directory access control via MCP client-side Roots.", + "type": "boolean" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/rust-mcp-filesystem/overview" + }, + "schemacrawlerAi": { + "title": "SchemaCrawler AI", + "description": "The SchemaCrawler AI MCP Server enables natural language interaction with your database schema using an MCP client in \"Agent\" mode. It allows users to explore tables, columns, foreign keys, triggers, stored procedures and more simply by asking questions like \"Explain the code for the interest calculation stored procedure\". You can also ask it to help with SQL, since it knows your schema. This is ideal for developers, DBAs, and data analysts who want to streamline schema comprehension and query development without diving into dense documentation.", + "required": [ + "urlConnectionJdbcUrl", + "serverConnectionServer", + "generalInfoLevel", + "volumeHostShare" + ], + "additionalProperties": false, + "properties": { + "generalInfoLevel": { + "description": "--info-level How much database metadata to retrieve", + "type": "string" + }, + "generalLogLevel": { + "type": "string" + }, + "schcrwlrDatabasePassword": { + "type": "string" + }, + "schcrwlrDatabaseUser": { + "type": "string" + }, + "serverConnectionDatabase": { + "description": "--database Database to connect to (optional)", + "type": "string" + }, + "serverConnectionHost": { + "description": "--host Database host (optional)", + "type": "string" + }, + "serverConnectionPort": { + "description": "--port Database port (optional)", + "type": "integer" + }, + "serverConnectionServer": { + "description": "--server SchemaCrawler database plugin", + "type": "string" + }, + "urlConnectionJdbcUrl": { + "description": "--url JDBC URL for database connection", + "type": "string" + }, + "volumeHostShare": { + "description": "Host volume to map within the Docker container", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/schemacrawler-ai/overview" + }, + "schoginiMcpImageBorder": { + "title": "Schogini MCP Image Border", + "description": "This adds a border to an image and returns base64 encoded image.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/schogini-mcp-image-border/overview" + }, + "scrapegraph": { + "title": "ScrapeGraph", + "description": "ScapeGraph MCP Server.", + "required": [ + "sgaiApiKey" + ], + "additionalProperties": false, + "properties": { + "sgaiApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/scrapegraph/overview" + }, + "scrapezy": { + "title": "Scrapezy", + "description": "A Model Context Protocol server for Scrapezy that enables AI models to extract structured data from websites.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/scrapezy/overview" + }, + "securenoteLink": { + "title": "Securenote.link mcp server", + "description": "SecureNote.link MCP Server - allowing AI agents to securely share sensitive information through end-to-end encrypted notes.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/securenote-link-mcp-server/overview" + }, + "semgrep": { + "title": "Semgrep", + "description": "MCP server for using Semgrep to scan code for security vulnerabilities.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/semgrep/overview" + }, + "sentry": { + "title": "Sentry (Archived)", + "description": "A Model Context Protocol server for retrieving and analyzing issues from Sentry.io. This server provides tools to inspect error reports, stacktraces, and other debugging information from your Sentry account.", + "required": [ + "authToken" + ], + "additionalProperties": false, + "properties": { + "authToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/sentry/overview" + }, + "sequa": { + "title": "Sequa.AI", + "description": "Stop stitching context for Copilot and Cursor. With Sequa MCP, your AI tools know your entire codebase and docs out of the box.", + "required": [ + "apiKey", + "mcpServerUrl" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "mcpServerUrl": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/sequa/overview" + }, + "sequentialthinking": { + "title": "Sequential Thinking (Reference)", + "description": "Dynamic and reflective problem-solving through thought sequences.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/sequentialthinking/overview" + }, + "shortIo": { + "title": "Short.io", + "description": "Access to Short.io's link shortener and analytics tools.", + "required": [ + "shortIoApiKey" + ], + "additionalProperties": false, + "properties": { + "shortIoApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/short-io/overview" + }, + "simplechecklist": { + "title": "SimpleCheckList MCP Server", + "description": "Advanced SimpleCheckList with MCP server and SQLite database for comprehensive task management. Features: • Complete project and task management system • Hierarchical organization (Projects → Groups → Task Lists → Tasks → Subtasks) • SQLite database for data persistence • RESTful API with comprehensive endpoints • MCP protocol compliance for AI assistant integration • Docker-optimized deployment with stability improvements **v1.0.1 Update**: Enhanced Docker stability with improved container lifecycle management. Default mode optimized for containerized deployment with reliable startup and shutdown processes. Perfect for AI assistants managing complex project workflows and task hierarchies.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/simplechecklist/overview" + }, + "singlestore": { + "title": "Singlestore", + "description": "MCP server for interacting with SingleStore Management API and services.", + "required": [ + "mcpApiKey" + ], + "additionalProperties": false, + "properties": { + "mcpApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/singlestore/overview" + }, + "slack": { + "title": "Slack (Archived)", + "description": "Interact with Slack Workspaces over the Slack API.", + "required": [ + "teamId" + ], + "additionalProperties": false, + "properties": { + "botToken": { + "type": "string" + }, + "channelIds": { + "type": "string" + }, + "teamId": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/slack/overview" + }, + "smartbear": { + "title": "SmartBear MCP Server", + "description": "MCP server for AI access to SmartBear tools, including BugSnag, Reflect, API Hub, PactFlow.", + "required": [ + "apiHubApiKey", + "bugsnagApiKey", + "bugsnagAuthToken", + "bugsnagEndpoint", + "pactBrokerBaseUrl", + "pactBrokerPassword", + "pactBrokerToken", + "pactBrokerUsername", + "reflectApiToken" + ], + "additionalProperties": false, + "properties": { + "apiHubApiKey": { + "type": "string" + }, + "bugsnagApiKey": { + "type": "string" + }, + "bugsnagAuthToken": { + "type": "string" + }, + "bugsnagEndpoint": { + "type": "string" + }, + "pactBrokerBaseUrl": { + "type": "string" + }, + "pactBrokerPassword": { + "type": "string" + }, + "pactBrokerToken": { + "type": "string" + }, + "pactBrokerUsername": { + "type": "string" + }, + "reflectApiToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/smartbear/overview" + }, + "sonarqube": { + "title": "SonarQube", + "description": "Interact with SonarQube Cloud, Server and Community build over the web API. Analyze code to identify quality and security issues.", + "required": [ + "org", + "token", + "url" + ], + "additionalProperties": false, + "properties": { + "org": { + "description": "Organization key for SonarQube Cloud, not required for SonarQube Server or Community Build", + "type": "string" + }, + "token": { + "type": "string" + }, + "url": { + "description": "URL of the SonarQube instance, to provide only for SonarQube Server or Community Build", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/sonarqube/overview" + }, + "sqlite": { + "title": "SQLite (Archived)", + "description": "Database interaction and business intelligence capabilities.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/SQLite/overview" + }, + "stackgen": { + "title": "StackGen", + "description": "AI-powered DevOps assistant for managing cloud infrastructure and applications.", + "required": [ + "url" + ], + "additionalProperties": false, + "properties": { + "token": { + "type": "string" + }, + "url": { + "description": "URL of your StackGen instance", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/stackgen/overview" + }, + "stackhawk": { + "title": "StackHawk", + "description": "A Model Context Protocol (MCP) server for integrating with StackHawk's security scanning platform. Provides security analytics, YAML configuration management, sensitive data/threat surface analysis, and anti-hallucination tools for LLMs.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/stackhawk/overview" + }, + "stripe": { + "title": "Stripe", + "description": "Interact with Stripe services over the Stripe API.", + "required": [ + "secretKey" + ], + "additionalProperties": false, + "properties": { + "secretKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/stripe/overview" + }, + "supadata": { + "title": "Supadata", + "description": "Official Supadata MCP Server - Adds powerful video \u0026 web scraping to Cursor, Claude and any other LLM clients.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/supadata/overview" + }, + "suzieq": { + "title": "Suzieq MCP", + "description": "MCP Server to interact with a SuzieQ network observability instance via its REST API.", + "required": [ + "apiEndpoint", + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiEndpoint": { + "type": "string" + }, + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/suzieq/overview" + }, + "taskOrchestrator": { + "title": "Task orchestrator", + "description": "Model Context Protocol (MCP) server for comprehensive task and feature management, providing AI assistants with a structured, context-efficient way to interact with project data.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/task-orchestrator/overview" + }, + "tavily": { + "title": "Tavily", + "description": "The Tavily MCP server provides seamless interaction with the tavily-search and tavily-extract tools, real-time web search capabilities through the tavily-search tool and Intelligent data extraction from web pages via the tavily-extract tool.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/tavily/overview" + }, + "teamwork": { + "title": "Teamwork", + "description": "Tools for Teamwork.com products.", + "required": [ + "twMcpBearerToken" + ], + "additionalProperties": false, + "properties": { + "twMcpBearerToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/teamwork/overview" + }, + "telnyx": { + "title": "Telnyx", + "description": "Enables interaction with powerful telephony, messaging, and AI assistant APIs.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/telnyx/overview" + }, + "tembo": { + "title": "Tembo", + "description": "MCP server for Tembo Cloud's platform API.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/tembo/overview" + }, + "terraform": { + "title": "Hashicorp Terraform", + "description": "The Terraform MCP Server provides seamless integration with Terraform ecosystem, enabling advanced automation and interaction capabilities for Infrastructure as Code (IaC) development.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/terraform/overview" + }, + "textToGraphql": { + "title": "Text-to-GraphQL", + "description": "Transform natural language queries into GraphQL queries using an AI agent. Provides schema management, query validation, execution, and history tracking.", + "required": [ + "graphqlApiKey", + "graphqlAuthType", + "graphqlEndpoint", + "modelName", + "modelTemperature", + "openaiApiKey" + ], + "additionalProperties": false, + "properties": { + "graphqlApiKey": { + "type": "string" + }, + "graphqlAuthType": { + "description": "Authentication method for GraphQL API", + "type": "string" + }, + "graphqlEndpoint": { + "type": "string" + }, + "modelName": { + "description": "OpenAI model to use", + "type": "string" + }, + "modelTemperature": { + "description": "Model temperature for responses", + "type": "number" + }, + "openaiApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/text-to-graphql/overview" + }, + "tigris": { + "title": "Tigris Data", + "description": "Tigris is a globally distributed S3-compatible object storage service that provides low latency anywhere in the world, enabling developers to store and access any amount of data for a wide range of use cases.", + "required": [ + "awsAccessKeyId", + "awsEndpointUrlS3" + ], + "additionalProperties": false, + "properties": { + "awsAccessKeyId": { + "type": "string" + }, + "awsEndpointUrlS3": { + "type": "string" + }, + "awsSecretAccessKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/tigris/overview" + }, + "time": { + "title": "Time (Reference)", + "description": "Time and timezone conversion capabilities.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/time/overview" + }, + "triplewhale": { + "title": "Triplewhale", + "description": "Triplewhale MCP Server.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/triplewhale/overview" + }, + "unrealEngine": { + "title": "Unreal Engine MCP Server", + "description": "A comprehensive Model Context Protocol (MCP) server that enables AI assistants to control Unreal Engine via Remote Control API. Built with TypeScript and designed for game development automation.", + "required": [ + "ueHost", + "ueRcHttpPort", + "ueRcWsPort" + ], + "additionalProperties": false, + "properties": { + "logLevel": { + "description": "Logging level", + "type": "string" + }, + "ueHost": { + "description": "Unreal Engine host address. Use: host.docker.internal for local UE on Windows/Mac Docker, 127.0.0.1 for Linux without Docker, or actual IP address (e.g., 192.168.1.100) for remote UE", + "type": "string" + }, + "ueRcHttpPort": { + "description": "Remote Control HTTP port", + "type": "string" + }, + "ueRcWsPort": { + "description": "Remote Control WebSocket port", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/unreal-engine-mcp-server/overview" + }, + "veyrax": { + "title": "VeyraX", + "description": "VeyraX MCP is the only connection you need to access all your tools in any MCP-compatible environment.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/veyrax/overview" + }, + "vizro": { + "title": "Vizro", + "description": "provides tools and templates to create a functioning Vizro chart or dashboard step by step.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/vizro/overview" + }, + "vulnNist": { + "title": "Vuln nist mcp server", + "description": "This MCP server exposes tools to query the NVD/CVE REST API and return formatted text results suitable for LLM consumption via the MCP protocol. It includes automatic query chunking for large date ranges and parallel processing for improved performance.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/vuln-nist-mcp-server/overview" + }, + "wayfound": { + "title": "Wayfound MCP", + "description": "Wayfound’s MCP server allows business users to govern, supervise, and improve AI Agents.", + "required": [ + "mcpApiKey" + ], + "additionalProperties": false, + "properties": { + "mcpApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/wayfound/overview" + }, + "webflow": { + "title": "Webflow", + "description": "Model Context Protocol (MCP) server for the Webflow Data API.", + "required": [ + "token" + ], + "additionalProperties": false, + "properties": { + "token": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/webflow/overview" + }, + "wikipedia": { + "title": "Wikipedia", + "description": "A Model Context Protocol (MCP) server that retrieves information from Wikipedia to provide context to LLMs.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/wikipedia-mcp/overview" + }, + "wolframAlpha": { + "title": "WolframAlpha", + "description": "Connect your chat repl to wolfram alpha computational intelligence.", + "required": [ + "wolframApiKey" + ], + "additionalProperties": false, + "properties": { + "wolframApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/wolfram-alpha/overview" + }, + "youtubeTranscript": { + "title": "YouTube transcripts", + "description": "Retrieves transcripts for given YouTube video URLs.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/youtube_transcript/overview" + }, + "zerodhaKite": { + "title": "Zerodha Kite Connect", + "description": "MCP server for Zerodha Kite Connect API - India's leading stock broker trading platform. Execute trades, manage portfolios, and access real-time market data for NSE, BSE, and other Indian exchanges.", + "required": [ + "kiteApiKey" + ], + "additionalProperties": false, + "properties": { + "kiteAccessToken": { + "description": "Access token obtained after OAuth authentication (optional - can be generated at runtime)", + "type": "string" + }, + "kiteApiKey": { + "description": "Your Kite Connect API key from the developer console", + "type": "string" + }, + "kiteApiSecret": { + "type": "string" + }, + "kiteRedirectUrl": { + "description": "OAuth redirect URL configured in your Kite Connect app", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/zerodha-kite/overview" + } + }, + "type": "object" +} \ No newline at end of file diff --git a/spec/openapi-volumecontent.yml b/spec/openapi-volumecontent.yml new file mode 100644 index 0000000..64809b3 --- /dev/null +++ b/spec/openapi-volumecontent.yml @@ -0,0 +1,329 @@ +openapi: 3.0.0 +info: + version: 0.1.0 + title: E2B API + +security: + - VolumeJWT: [] + +components: + securitySchemes: + VolumeJWT: + type: http + scheme: bearer + bearerFormat: JWT + + parameters: + volumeID: + name: volumeID + in: path + required: true + schema: + type: string + path: + name: path + in: query + required: true + schema: + type: string + + responses: + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Authentication error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + schemas: + Error: + required: + - code + - message + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + + VolumeEntryStat: + type: object + properties: + name: + type: string + type: + type: string + enum: [unknown, file, directory, symlink] + path: + type: string + size: + type: integer + format: int64 + mode: + type: integer + format: uint32 + uid: + type: integer + format: uint32 + gid: + type: integer + format: uint32 + atime: + type: string + format: date-time + mtime: + type: string + format: date-time + ctime: + type: string + format: date-time + target: + type: string + required: + - name + - type + - path + - size + - mode + - uid + - gid + - atime + - mtime + - ctime + + VolumeDirectoryListing: + type: array + items: + $ref: '#/components/schemas/VolumeEntryStat' + +paths: + /volumecontent/{volumeID}/path: + get: + description: Get path information + tags: [volumes] + parameters: + - $ref: '#/components/parameters/volumeID' + - $ref: '#/components/parameters/path' + responses: + '200': + description: Successfully retrieved path information + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeEntryStat' + '404': + $ref: '#/components/responses/404' + + patch: + description: Update path metadata + tags: [volumes] + parameters: + - $ref: '#/components/parameters/volumeID' + - $ref: '#/components/parameters/path' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + uid: + type: integer + format: uint32 + gid: + type: integer + format: uint32 + mode: + type: integer + format: uint32 + responses: + '200': + description: "Successfully updated a file's metadata" + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeEntryStat' + '400': + description: 'Invalid metadata provided' + '404': + description: 'path not found' + '500': + description: 'Internal server error' + + delete: + description: Delete a path + tags: [volumes] + parameters: + - $ref: '#/components/parameters/volumeID' + - $ref: '#/components/parameters/path' + responses: + '204': + description: Successfully deleted a path + '404': + $ref: '#/components/responses/404' + + /volumecontent/{volumeID}/dir: + get: + description: List directory contents + tags: [volumes] + parameters: + - $ref: '#/components/parameters/volumeID' + - $ref: '#/components/parameters/path' + - name: depth + in: query + description: Number of layers deep to recurse into the directory + schema: + type: integer + format: uint32 + default: 1 + responses: + '200': + description: 'Successfully retrieved a directory listing' + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeDirectoryListing' + '400': + description: 'Invalid path provided' + '404': + description: 'path not found' + '500': + $ref: '#/components/responses/500' + post: + description: 'Create a directory' + tags: [volumes] + parameters: + - $ref: '#/components/parameters/volumeID' + - $ref: '#/components/parameters/path' + - name: uid + in: query + description: User ID of the created directory + schema: + type: integer + format: uint32 + - name: gid + in: query + description: Group ID of the created directory + schema: + type: integer + format: uint32 + - name: mode + in: query + description: Mode of the created directory + schema: + type: integer + format: uint32 + - name: force + in: query + description: Create the parents of a directory if they don't exist + schema: + type: boolean + responses: + '201': + description: 'Successfully created a directory' + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeEntryStat' + '404': + description: 'path not found' + '500': + $ref: '#/components/responses/500' + + /volumecontent/{volumeID}/file: + get: + description: Download file + tags: [volumes] + parameters: + - $ref: '#/components/parameters/volumeID' + - $ref: '#/components/parameters/path' + responses: + '200': + description: 'Successfully downloaded a file' + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + description: 'path not found' + '500': + $ref: '#/components/responses/500' + put: + description: Upload file + tags: [volumes] + parameters: + - $ref: '#/components/parameters/volumeID' + - $ref: '#/components/parameters/path' + - name: uid + in: query + description: User ID of the uploaded file + schema: + type: integer + format: uint32 + - name: gid + in: query + description: Group ID of the uploaded file + schema: + type: integer + format: uint32 + - name: mode + in: query + description: Mode of the uploaded file + schema: + type: integer + format: uint32 + - name: force + in: query + description: Force overwrite of an existing file + schema: + type: boolean + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + '201': + description: 'Successfully created a file' + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeEntryStat' + '404': + description: 'path not found' + '500': + $ref: '#/components/responses/500' diff --git a/spec/openapi.yml b/spec/openapi.yml new file mode 100644 index 0000000..0f20f8d --- /dev/null +++ b/spec/openapi.yml @@ -0,0 +1,3653 @@ +openapi: 3.0.0 +info: + version: 0.1.0 + title: E2B API + +servers: + - url: https://api.e2b.app + +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + AccessTokenAuth: + type: http + scheme: bearer + bearerFormat: access_token + # Generated code uses security schemas in the alphabetical order. + # In order to check first the token, and then the team (so we can already use the user), + # there is a 1 and 2 present in the names of the security schemas. + Supabase1TokenAuth: + type: apiKey + in: header + name: X-Supabase-Token + Supabase2TeamAuth: + type: apiKey + in: header + name: X-Supabase-Team + # AuthProviderBearerAuth / AuthProviderTeamAuth: B before T in the name + # so Bearer is validated before Team (same reason as Supabase1/2 above). + AuthProviderBearerAuth: + type: http + scheme: bearer + bearerFormat: access_token + AuthProviderTeamAuth: + type: apiKey + in: header + name: X-Team-ID + AdminTokenAuth: + type: apiKey + in: header + name: X-Admin-Token + + parameters: + templateID: + name: templateID + in: path + required: true + schema: + type: string + buildID: + name: buildID + in: path + required: true + schema: + type: string + sandboxID: + name: sandboxID + in: path + required: true + schema: + type: string + teamID: + name: teamID + in: path + required: true + schema: + type: string + nodeID: + name: nodeID + in: path + required: true + schema: + type: string + apiKeyID: + name: apiKeyID + in: path + required: true + schema: + type: string + accessTokenID: + name: accessTokenID + in: path + required: true + schema: + type: string + snapshotID: + name: snapshotID + in: path + required: true + schema: + type: string + description: Identifier of the snapshot (template ID) + tag: + name: tag + in: path + required: true + schema: + type: string + description: Tag name + paginationLimit: + name: limit + in: query + description: Maximum number of items to return per page + required: false + schema: + type: integer + format: int32 + minimum: 1 + default: 100 + maximum: 100 + paginationNextToken: + name: nextToken + in: query + description: Cursor to start the list from + required: false + schema: + type: string + volumeID: + name: volumeID + in: path + required: true + schema: + type: string + + responses: + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Authentication error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + schemas: + Team: + required: + - teamID + - name + - apiKey + - isDefault + properties: + teamID: + type: string + description: Identifier of the team + name: + type: string + description: Name of the team + apiKey: + type: string + description: API key for the team + isDefault: + type: boolean + description: Whether the team is the default team + + TeamUser: + required: + - id + - email + properties: + id: + type: string + format: uuid + description: Identifier of the user + email: + type: string + nullable: true + deprecated: true + default: null + description: Email of the user + + TemplateUpdateRequest: + properties: + public: + type: boolean + description: Whether the template is public or only accessible by the team + + TemplateUpdateResponse: + required: + - names + properties: + names: + type: array + description: Names of the template (namespace/alias format when namespaced) + items: + type: string + + CPUCount: + type: integer + format: int32 + minimum: 1 + description: CPU cores for the sandbox + + MemoryMB: + type: integer + format: int32 + minimum: 128 + description: Memory for the sandbox in MiB + + DiskSizeMB: + type: integer + format: int32 + minimum: 0 + description: Disk size for the sandbox in MiB + + EnvdVersion: + type: string + description: Version of the envd running in the sandbox + + SandboxMetadata: + additionalProperties: + type: string + description: Metadata of the sandbox + + SandboxState: + type: string + description: State of the sandbox + enum: + - running + - paused + + SnapshotInfo: + type: object + required: + - snapshotID + - names + properties: + snapshotID: + type: string + description: Identifier of the snapshot template including the tag. Uses namespace/alias when a name was provided (e.g. team-slug/my-snapshot:default), otherwise falls back to the raw template ID (e.g. abc123:default). + names: + type: array + items: + type: string + description: Full names of the snapshot template including team namespace and tag (e.g. team-slug/my-snapshot:v2) + + EnvVars: + additionalProperties: + type: string + description: Environment variables for the sandbox + + Mcp: + type: object + description: MCP configuration for the sandbox + additionalProperties: {} + nullable: true + + SandboxNetworkConfig: + type: object + properties: + allowPublicTraffic: + type: boolean + default: true + description: Specify if the sandbox URLs should be accessible only with authentication. + allowOut: + type: array + description: List of allowed destinations for egress traffic. Each entry can be a CIDR block (e.g. "8.8.8.8/32"), a bare IP address (e.g. "8.8.8.8"), or a domain name (e.g. "example.com", "*.example.com"). Allowed entries always take precedence over denied entries. + items: + type: string + denyOut: + type: array + description: List of denied CIDR blocks or IP addresses for egress traffic. Domain names are not supported for deny rules. + items: + type: string + maskRequestHost: + type: string + description: Specify host mask which will be used for all sandbox requests + rules: + type: object + description: > + Per-domain transform rules applied to matching egress HTTP/HTTPS requests. + Keys are domains (e.g. "api.example.com", "example.com"). + A domain listed here is not automatically allowed - use allowOut to permit the traffic. + additionalProperties: + type: array + items: + $ref: '#/components/schemas/SandboxNetworkRule' + + SandboxNetworkUpdateConfig: + type: object + description: Network configuration update for a running sandbox. Replaces the current egress rules with the provided configuration. Omitting a field clears it. + properties: + allowOut: + type: array + description: List of allowed destinations for egress traffic. Each entry can be a CIDR block (e.g. "8.8.8.8/32"), a bare IP address (e.g. "8.8.8.8"), or a domain name (e.g. "example.com", "*.example.com"). Allowed entries always take precedence over denied entries. + items: + type: string + denyOut: + type: array + description: List of denied CIDR blocks or IP addresses for egress traffic. Domain names are not supported for deny rules. + items: + type: string + rules: + type: object + description: Per-domain transform rules. Replaces all existing rules when provided. + additionalProperties: + type: array + items: + $ref: '#/components/schemas/SandboxNetworkRule' + allow_internet_access: + type: boolean + description: + Allow sandbox to access the internet. When set to false, it behaves the same as specifying denyOut + to 0.0.0.0/0 in the network config. + + SandboxNetworkRule: + type: object + description: Transform rule applied to egress requests matching a domain pattern. + properties: + transform: + $ref: '#/components/schemas/SandboxNetworkTransform' + + SandboxNetworkTransform: + type: object + description: Transformations applied to matching egress requests before forwarding. + properties: + headers: + type: object + description: > + HTTP headers to inject or override in matching requests. + An existing header with the same name is replaced. Values are plain strings; + secret resolution happens client-side before sending to the API. + additionalProperties: + type: string + + SandboxAutoResumeEnabled: + type: boolean + description: Auto-resume enabled flag for paused sandboxes. Default false. + default: false + + SandboxAutoResumeConfig: + type: object + description: Auto-resume configuration for paused sandboxes. + required: + - enabled + properties: + enabled: + $ref: '#/components/schemas/SandboxAutoResumeEnabled' + + SandboxOnTimeout: + type: string + description: Action taken when the sandbox times out. + enum: + - kill + - pause + + SandboxLifecycle: + type: object + description: Sandbox lifecycle policy returned by sandbox info. + required: + - autoResume + - onTimeout + properties: + autoResume: + type: boolean + description: Whether the sandbox can auto-resume. + onTimeout: + $ref: '#/components/schemas/SandboxOnTimeout' + + SandboxLog: + description: Log entry with timestamp and line + required: + - timestamp + - line + properties: + timestamp: + type: string + format: date-time + description: Timestamp of the log entry + line: + type: string + description: Log line content + + SandboxLogEntry: + required: + - timestamp + - level + - message + - fields + properties: + timestamp: + type: string + format: date-time + description: Timestamp of the log entry + message: + type: string + description: Log message content + level: + $ref: '#/components/schemas/LogLevel' + fields: + type: object + additionalProperties: + type: string + + SandboxLogs: + required: + - logs + - logEntries + properties: + logs: + description: Logs of the sandbox + type: array + items: + $ref: '#/components/schemas/SandboxLog' + logEntries: + description: Structured logs of the sandbox + type: array + items: + $ref: '#/components/schemas/SandboxLogEntry' + + SandboxLogsV2Response: + required: + - logs + properties: + logs: + default: [] + description: Sandbox logs structured + type: array + items: + $ref: '#/components/schemas/SandboxLogEntry' + + SandboxMetric: + description: Metric entry with timestamp and line + required: + - timestamp + - timestampUnix + - cpuCount + - cpuUsedPct + - memUsed + - memTotal + - memCache + - diskUsed + - diskTotal + properties: + timestamp: + type: string + format: date-time + deprecated: true + description: Timestamp of the metric entry + timestampUnix: + type: integer + format: int64 + description: Timestamp of the metric entry in Unix time (seconds since epoch) + cpuCount: + type: integer + format: int32 + description: Number of CPU cores + cpuUsedPct: + type: number + format: float + description: CPU usage percentage + memUsed: + type: integer + format: int64 + description: Memory used in bytes + memTotal: + type: integer + format: int64 + description: Total memory in bytes + memCache: + type: integer + format: int64 + description: Cached memory (page cache) in bytes + diskUsed: + type: integer + format: int64 + description: Disk used in bytes + diskTotal: + type: integer + format: int64 + description: Total disk space in bytes + + SandboxVolumeMount: + type: object + properties: + name: + type: string + description: Name of the volume + path: + type: string + description: Path of the volume + required: + - name + - path + + Sandbox: + required: + - templateID + - sandboxID + - clientID + - envdVersion + properties: + templateID: + type: string + description: Identifier of the template from which is the sandbox created + sandboxID: + type: string + description: Identifier of the sandbox + alias: + type: string + description: Alias of the template + clientID: + type: string + deprecated: true + description: Identifier of the client + envdVersion: + $ref: '#/components/schemas/EnvdVersion' + envdAccessToken: + type: string + description: Access token used for envd communication + trafficAccessToken: + type: string + nullable: true + description: Token required for accessing sandbox via proxy. + domain: + type: string + nullable: true + description: Base domain where the sandbox traffic is accessible + + SandboxDetail: + required: + - templateID + - sandboxID + - clientID + - startedAt + - cpuCount + - memoryMB + - diskSizeMB + - endAt + - state + - envdVersion + properties: + templateID: + type: string + description: Identifier of the template from which is the sandbox created + alias: + type: string + description: Alias of the template + sandboxID: + type: string + description: Identifier of the sandbox + clientID: + type: string + deprecated: true + description: Identifier of the client + startedAt: + type: string + format: date-time + description: Time when the sandbox was started + endAt: + type: string + format: date-time + description: Time when the sandbox will expire + envdVersion: + $ref: '#/components/schemas/EnvdVersion' + envdAccessToken: + type: string + description: Access token used for envd communication + allowInternetAccess: + type: boolean + nullable: true + description: Whether internet access was explicitly enabled or disabled for the sandbox. Null means it was not explicitly set. + domain: + type: string + nullable: true + description: Base domain where the sandbox traffic is accessible + cpuCount: + $ref: '#/components/schemas/CPUCount' + memoryMB: + $ref: '#/components/schemas/MemoryMB' + diskSizeMB: + $ref: '#/components/schemas/DiskSizeMB' + metadata: + $ref: '#/components/schemas/SandboxMetadata' + state: + $ref: '#/components/schemas/SandboxState' + network: + $ref: '#/components/schemas/SandboxNetworkConfig' + lifecycle: + $ref: '#/components/schemas/SandboxLifecycle' + volumeMounts: + type: array + items: + $ref: '#/components/schemas/SandboxVolumeMount' + + ListedSandbox: + required: + - templateID + - sandboxID + - clientID + - startedAt + - cpuCount + - memoryMB + - diskSizeMB + - endAt + - state + - envdVersion + properties: + templateID: + type: string + description: Identifier of the template from which is the sandbox created + alias: + type: string + description: Alias of the template + sandboxID: + type: string + description: Identifier of the sandbox + clientID: + type: string + deprecated: true + description: Identifier of the client + startedAt: + type: string + format: date-time + description: Time when the sandbox was started + endAt: + type: string + format: date-time + description: Time when the sandbox will expire + cpuCount: + $ref: '#/components/schemas/CPUCount' + memoryMB: + $ref: '#/components/schemas/MemoryMB' + diskSizeMB: + $ref: '#/components/schemas/DiskSizeMB' + metadata: + $ref: '#/components/schemas/SandboxMetadata' + state: + $ref: '#/components/schemas/SandboxState' + envdVersion: + $ref: '#/components/schemas/EnvdVersion' + volumeMounts: + type: array + items: + $ref: '#/components/schemas/SandboxVolumeMount' + + SandboxesWithMetrics: + required: + - sandboxes + properties: + sandboxes: + additionalProperties: + $ref: '#/components/schemas/SandboxMetric' + + NewSandbox: + required: + - templateID + properties: + templateID: + type: string + description: Identifier of the required template + timeout: + type: integer + format: int32 + minimum: 0 + default: 15 + description: Time to live for the sandbox in seconds. + autoPause: + type: boolean + default: false + description: Automatically pauses the sandbox after the timeout + autoPauseMemory: + type: boolean + default: true + description: >- + Controls the snapshot kind taken when the sandbox auto-pauses on + timeout (only relevant when autoPause is true). When false, the + auto-pause drops the in-memory state and persists only the + filesystem (a filesystem-only snapshot); resuming it cold-boots + (reboots) the sandbox from disk. Such a snapshot cannot be + auto-resumed by traffic and must be resumed explicitly, so it cannot + be combined with autoResume. Defaults to true (full memory snapshot). + autoResume: + $ref: '#/components/schemas/SandboxAutoResumeConfig' + secure: + type: boolean + description: Secure all system communication with sandbox + allow_internet_access: + type: boolean + description: + Allow sandbox to access the internet. When set to false, it behaves the same as specifying denyOut + to 0.0.0.0/0 in the network config. + network: + $ref: '#/components/schemas/SandboxNetworkConfig' + metadata: + $ref: '#/components/schemas/SandboxMetadata' + envVars: + $ref: '#/components/schemas/EnvVars' + mcp: + $ref: '#/components/schemas/Mcp' + volumeMounts: + type: array + items: + $ref: '#/components/schemas/SandboxVolumeMount' + + ResumedSandbox: + properties: + timeout: + type: integer + format: int32 + minimum: 0 + default: 15 + description: Time to live for the sandbox in seconds. + autoPause: + type: boolean + deprecated: true + description: Automatically pauses the sandbox after the timeout + + SandboxPauseRequest: + type: object + properties: + memory: + type: boolean + default: true + description: >- + Whether to capture a full memory snapshot. When false, only the + filesystem is persisted and resuming the sandbox cold-boots + (reboots) it from disk, losing in-memory state, running processes, + and open connections. Resume it with an explicit request (connect or + resume); auto-resume, which can be triggered by arbitrary traffic, + refuses such a sandbox. Defaults to true. + + ConnectSandbox: + type: object + required: + - timeout + properties: + timeout: + description: Timeout in seconds from the current time after which the sandbox should expire + type: integer + format: int32 + minimum: 0 + + TeamMetric: + description: Team metric with timestamp + required: + - timestamp + - timestampUnix + - concurrentSandboxes + - sandboxStartRate + properties: + timestamp: + type: string + format: date-time + deprecated: true + description: Timestamp of the metric entry + timestampUnix: + type: integer + format: int64 + description: Timestamp of the metric entry in Unix time (seconds since epoch) + concurrentSandboxes: + type: integer + format: int32 + description: The number of concurrent sandboxes for the team + sandboxStartRate: + type: number + format: float + description: Number of sandboxes started per second + + MaxTeamMetric: + description: Team metric with timestamp + required: + - timestamp + - timestampUnix + - value + properties: + timestamp: + type: string + format: date-time + deprecated: true + description: Timestamp of the metric entry + timestampUnix: + type: integer + format: int64 + description: Timestamp of the metric entry in Unix time (seconds since epoch) + value: + type: number + description: The maximum value of the requested metric in the given interval + + AdminSandboxKillResult: + required: + - killedCount + - failedCount + properties: + killedCount: + type: integer + description: Number of sandboxes successfully killed + failedCount: + type: integer + description: Number of sandboxes that failed to kill + + AdminBuildCancelResult: + required: + - cancelledCount + - failedCount + properties: + cancelledCount: + type: integer + description: Number of builds successfully cancelled + failedCount: + type: integer + description: Number of builds that failed to cancel + + VolumeToken: + type: object + properties: + token: + type: string + required: + - token + + Template: + required: + - templateID + - buildID + - cpuCount + - memoryMB + - diskSizeMB + - public + - createdAt + - updatedAt + - createdBy + - lastSpawnedAt + - spawnCount + - buildCount + - envdVersion + - aliases + - names + - buildStatus + properties: + templateID: + type: string + description: Identifier of the template + buildID: + type: string + description: Identifier of the last successful build for given template + cpuCount: + $ref: '#/components/schemas/CPUCount' + memoryMB: + $ref: '#/components/schemas/MemoryMB' + diskSizeMB: + $ref: '#/components/schemas/DiskSizeMB' + public: + type: boolean + description: Whether the template is public or only accessible by the team + aliases: + type: array + description: Aliases of the template + deprecated: true + items: + type: string + names: + type: array + description: Names of the template (namespace/alias format when namespaced) + items: + type: string + createdAt: + type: string + format: date-time + description: Time when the template was created + updatedAt: + type: string + format: date-time + description: Time when the template was last updated + createdBy: + allOf: + - $ref: '#/components/schemas/TeamUser' + nullable: true + lastSpawnedAt: + type: string + nullable: true + format: date-time + description: Time when the template was last used + spawnCount: + type: integer + format: int64 + description: Number of times the template was used + buildCount: + type: integer + format: int32 + description: Number of times the template was built + envdVersion: + $ref: '#/components/schemas/EnvdVersion' + buildStatus: + $ref: '#/components/schemas/TemplateBuildStatus' + + TemplateRequestResponseV3: + required: + - templateID + - buildID + - public + - aliases + - names + - tags + properties: + templateID: + type: string + description: Identifier of the template + buildID: + type: string + description: Identifier of the last successful build for given template + public: + type: boolean + description: Whether the template is public or only accessible by the team + names: + type: array + description: Names of the template + items: + type: string + tags: + type: array + description: Tags assigned to the template build + items: + type: string + aliases: + type: array + description: Aliases of the template + deprecated: true + items: + type: string + + TemplateLegacy: + required: + - templateID + - buildID + - cpuCount + - memoryMB + - diskSizeMB + - public + - createdAt + - updatedAt + - createdBy + - lastSpawnedAt + - spawnCount + - buildCount + - envdVersion + - aliases + properties: + templateID: + type: string + description: Identifier of the template + buildID: + type: string + description: Identifier of the last successful build for given template + cpuCount: + $ref: '#/components/schemas/CPUCount' + memoryMB: + $ref: '#/components/schemas/MemoryMB' + diskSizeMB: + $ref: '#/components/schemas/DiskSizeMB' + public: + type: boolean + description: Whether the template is public or only accessible by the team + aliases: + type: array + description: Aliases of the template + items: + type: string + createdAt: + type: string + format: date-time + description: Time when the template was created + updatedAt: + type: string + format: date-time + description: Time when the template was last updated + createdBy: + allOf: + - $ref: '#/components/schemas/TeamUser' + nullable: true + lastSpawnedAt: + type: string + nullable: true + format: date-time + description: Time when the template was last used + spawnCount: + type: integer + format: int64 + description: Number of times the template was used + buildCount: + type: integer + format: int32 + description: Number of times the template was built + envdVersion: + $ref: '#/components/schemas/EnvdVersion' + + TemplateBuild: + required: + - buildID + - status + - createdAt + - updatedAt + - cpuCount + - memoryMB + properties: + buildID: + type: string + format: uuid + description: Identifier of the build + status: + $ref: '#/components/schemas/TemplateBuildStatus' + createdAt: + type: string + format: date-time + description: Time when the build was created + updatedAt: + type: string + format: date-time + description: Time when the build was last updated + finishedAt: + type: string + format: date-time + description: Time when the build was finished + cpuCount: + $ref: '#/components/schemas/CPUCount' + memoryMB: + $ref: '#/components/schemas/MemoryMB' + diskSizeMB: + $ref: '#/components/schemas/DiskSizeMB' + envdVersion: + $ref: '#/components/schemas/EnvdVersion' + + TemplateWithBuilds: + required: + - templateID + - public + - aliases + - names + - createdAt + - updatedAt + - lastSpawnedAt + - spawnCount + - builds + properties: + templateID: + type: string + description: Identifier of the template + public: + type: boolean + description: Whether the template is public or only accessible by the team + aliases: + type: array + description: Aliases of the template + deprecated: true + items: + type: string + names: + type: array + description: Names of the template (namespace/alias format when namespaced) + items: + type: string + createdAt: + type: string + format: date-time + description: Time when the template was created + updatedAt: + type: string + format: date-time + description: Time when the template was last updated + lastSpawnedAt: + type: string + nullable: true + format: date-time + description: Time when the template was last used + spawnCount: + type: integer + format: int64 + description: Number of times the template was used + builds: + type: array + description: List of builds for the template + items: + $ref: '#/components/schemas/TemplateBuild' + + TemplateAliasResponse: + required: + - templateID + - public + properties: + templateID: + type: string + description: Identifier of the template + public: + type: boolean + description: Whether the template is public or only accessible by the team + + TemplateBuildRequest: + required: + - dockerfile + properties: + alias: + description: Alias of the template + type: string + dockerfile: + description: Dockerfile for the template + type: string + teamID: + type: string + description: Identifier of the team + startCmd: + description: Start command to execute in the template after the build + type: string + readyCmd: + description: Ready check command to execute in the template after the build + type: string + cpuCount: + $ref: '#/components/schemas/CPUCount' + memoryMB: + $ref: '#/components/schemas/MemoryMB' + + TemplateStep: + description: Step in the template build process + required: + - type + properties: + type: + type: string + description: Type of the step + args: + default: [] + type: array + description: Arguments for the step + items: + type: string + filesHash: + type: string + description: Hash of the files used in the step + force: + default: false + type: boolean + description: Whether the step should be forced to run regardless of the cache + + TemplateBuildRequestV3: + properties: + name: + description: Name of the template. Can include a tag with colon separator (e.g. "my-template" or "my-template:v1"). If tag is included, it will be treated as if the tag was provided in the tags array. + type: string + tags: + type: array + description: Tags to assign to the template build + items: + type: string + alias: + description: Alias of the template. Deprecated, use name instead. + type: string + deprecated: true + teamID: + deprecated: true + type: string + description: Identifier of the team + cpuCount: + $ref: '#/components/schemas/CPUCount' + memoryMB: + $ref: '#/components/schemas/MemoryMB' + + TemplateBuildRequestV2: + required: + - alias + properties: + alias: + description: Alias of the template + type: string + teamID: + deprecated: true + type: string + description: Identifier of the team + cpuCount: + $ref: '#/components/schemas/CPUCount' + memoryMB: + $ref: '#/components/schemas/MemoryMB' + + FromImageRegistry: + oneOf: + - $ref: '#/components/schemas/AWSRegistry' + - $ref: '#/components/schemas/GCPRegistry' + - $ref: '#/components/schemas/GeneralRegistry' + discriminator: + propertyName: type + mapping: + aws: '#/components/schemas/AWSRegistry' + gcp: '#/components/schemas/GCPRegistry' + registry: '#/components/schemas/GeneralRegistry' + + AWSRegistry: + type: object + required: + - type + - awsAccessKeyId + - awsSecretAccessKey + - awsRegion + properties: + type: + type: string + enum: [aws] + description: Type of registry authentication + awsAccessKeyId: + type: string + description: AWS Access Key ID for ECR authentication + awsSecretAccessKey: + type: string + description: AWS Secret Access Key for ECR authentication + awsRegion: + type: string + description: AWS Region where the ECR registry is located + + GCPRegistry: + type: object + required: + - type + - serviceAccountJson + properties: + type: + type: string + enum: [gcp] + description: Type of registry authentication + serviceAccountJson: + type: string + description: Service Account JSON for GCP authentication + + GeneralRegistry: + type: object + required: + - type + - username + - password + properties: + type: + type: string + enum: [registry] + description: Type of registry authentication + username: + type: string + description: Username to use for the registry + password: + type: string + description: Password to use for the registry + + TemplateBuildStartV2: + type: object + properties: + fromImage: + type: string + description: Image to use as a base for the template build + fromTemplate: + type: string + description: Template to use as a base for the template build + fromImageRegistry: + $ref: '#/components/schemas/FromImageRegistry' + force: + default: false + type: boolean + description: Whether the whole build should be forced to run regardless of the cache + steps: + default: [] + description: List of steps to execute in the template build + type: array + items: + $ref: '#/components/schemas/TemplateStep' + startCmd: + description: Start command to execute in the template after the build + type: string + readyCmd: + description: Ready check command to execute in the template after the build + type: string + + TemplateBuildFileUpload: + required: + - present + properties: + present: + type: boolean + description: Whether the file is already present in the cache + url: + description: Url where the file should be uploaded to + type: string + + LogLevel: + type: string + description: State of the sandbox + enum: + - debug + - info + - warn + - error + + BuildLogEntry: + required: + - timestamp + - message + - level + properties: + timestamp: + type: string + format: date-time + description: Timestamp of the log entry + message: + type: string + description: Log message content + level: + $ref: '#/components/schemas/LogLevel' + step: + type: string + description: Step in the build process related to the log entry + + BuildStatusReason: + required: + - message + properties: + message: + type: string + description: Message with the status reason, currently reporting only for error status + step: + type: string + description: Step that failed + logEntries: + default: [] + description: Log entries related to the status reason + type: array + items: + $ref: '#/components/schemas/BuildLogEntry' + + TemplateBuildStatus: + type: string + description: Status of the template build + enum: + - building + - waiting + - ready + - error + + TemplateBuildInfo: + required: + - templateID + - buildID + - status + - logs + - logEntries + properties: + logs: + default: [] + description: Build logs + type: array + items: + type: string + logEntries: + default: [] + description: Build logs structured + type: array + items: + $ref: '#/components/schemas/BuildLogEntry' + templateID: + type: string + description: Identifier of the template + buildID: + type: string + description: Identifier of the build + status: + $ref: '#/components/schemas/TemplateBuildStatus' + reason: + $ref: '#/components/schemas/BuildStatusReason' + + TemplateBuildLogsResponse: + required: + - logs + properties: + logs: + default: [] + description: Build logs structured + type: array + items: + $ref: '#/components/schemas/BuildLogEntry' + + LogsDirection: + type: string + description: Direction of the logs that should be returned + enum: + - forward + - backward + x-enum-varnames: + - LogsDirectionForward + - LogsDirectionBackward + + LogsSource: + type: string + description: Source of the logs that should be returned + enum: + - temporary + - persistent + x-enum-varnames: + - LogsSourceTemporary + - LogsSourcePersistent + + NodeStatus: + type: string + description: | + Status of the node. + - draining: the node is bound to be shut down. It will not accept new sandboxes and will stop once all existing sandboxes are done. + - standby: the node is not actively used, but it can return to ready and continue serving traffic. + enum: + - ready + - draining + - connecting + - unhealthy + - standby + x-enum-varnames: + - NodeStatusReady + - NodeStatusDraining + - NodeStatusConnecting + - NodeStatusUnhealthy + - NodeStatusStandby + + NodeStatusChange: + required: + - status + properties: + clusterID: + type: string + format: uuid + description: Identifier of the cluster + status: + $ref: '#/components/schemas/NodeStatus' + + DiskMetrics: + required: + - mountPoint + - device + - filesystemType + - usedBytes + - totalBytes + properties: + mountPoint: + type: string + description: Mount point of the disk + device: + type: string + description: Device name + filesystemType: + type: string + description: Filesystem type (e.g., ext4, xfs) + usedBytes: + type: integer + format: uint64 + description: Used space in bytes + totalBytes: + type: integer + format: uint64 + description: Total space in bytes + + NodeMetrics: + description: Node metrics + required: + - allocatedCPU + - allocatedMemoryBytes + - cpuPercent + - memoryUsedBytes + - cpuCount + - memoryTotalBytes + - disks + properties: + allocatedCPU: + type: integer + format: uint32 + description: Number of allocated CPU cores + cpuPercent: + type: integer + format: uint32 + description: Node CPU usage percentage + cpuCount: + type: integer + format: uint32 + description: Total number of CPU cores on the node + allocatedMemoryBytes: + type: integer + format: uint64 + description: Amount of allocated memory in bytes + memoryUsedBytes: + type: integer + format: uint64 + description: Node memory used in bytes + memoryTotalBytes: + type: integer + format: uint64 + description: Total node memory in bytes + disks: + type: array + description: Detailed metrics for each disk/mount point + items: + $ref: '#/components/schemas/DiskMetrics' + MachineInfo: + required: + - cpuFamily + - cpuModel + - cpuModelName + - cpuArchitecture + properties: + cpuFamily: + type: string + description: CPU family of the node + cpuModel: + type: string + description: CPU model of the node + cpuModelName: + type: string + description: CPU model name of the node + cpuArchitecture: + type: string + description: CPU architecture of the node + + Node: + required: + - id + - serviceInstanceID + - clusterID + - status + - sandboxCount + - metrics + - createSuccesses + - createFails + - sandboxStartingCount + - version + - commit + - machineInfo + properties: + version: + type: string + description: Version of the orchestrator + commit: + type: string + description: Commit of the orchestrator + id: + type: string + description: Identifier of the node + serviceInstanceID: + type: string + description: Service instance identifier of the node + clusterID: + type: string + description: Identifier of the cluster + machineInfo: + $ref: '#/components/schemas/MachineInfo' + status: + $ref: '#/components/schemas/NodeStatus' + sandboxCount: + type: integer + format: uint32 + description: Number of sandboxes running on the node + metrics: + $ref: '#/components/schemas/NodeMetrics' + createSuccesses: + type: integer + format: uint64 + description: Number of sandbox create successes + createFails: + type: integer + format: uint64 + description: Number of sandbox create fails + sandboxStartingCount: + type: integer + format: int + description: Number of starting Sandboxes + + NodeDetail: + required: + - id + - serviceInstanceID + - clusterID + - status + - sandboxCount + - cachedBuilds + - createSuccesses + - createFails + - version + - commit + - metrics + - machineInfo + properties: + clusterID: + type: string + description: Identifier of the cluster + version: + type: string + description: Version of the orchestrator + commit: + type: string + description: Commit of the orchestrator + id: + type: string + description: Identifier of the node + serviceInstanceID: + type: string + description: Service instance identifier of the node + machineInfo: + $ref: '#/components/schemas/MachineInfo' + status: + $ref: '#/components/schemas/NodeStatus' + sandboxCount: + type: integer + format: uint32 + description: Number of sandboxes running on the node + metrics: + $ref: '#/components/schemas/NodeMetrics' + cachedBuilds: + type: array + description: List of cached builds id on the node + items: + type: string + createSuccesses: + type: integer + format: uint64 + description: Number of sandbox create successes + createFails: + type: integer + format: uint64 + description: Number of sandbox create fails + + CreatedAccessToken: + required: + - id + - name + - token + - mask + - createdAt + properties: + id: + type: string + format: uuid + description: Identifier of the access token + name: + type: string + description: Name of the access token + token: + type: string + description: The fully created access token + mask: + $ref: '#/components/schemas/IdentifierMaskingDetails' + createdAt: + type: string + format: date-time + description: Timestamp of access token creation + + NewAccessToken: + required: + - name + properties: + name: + type: string + description: Name of the access token + + TeamAPIKey: + required: + - id + - name + - mask + - createdAt + properties: + id: + type: string + format: uuid + description: Identifier of the API key + name: + type: string + description: Name of the API key + mask: + $ref: '#/components/schemas/IdentifierMaskingDetails' + createdAt: + type: string + format: date-time + description: Timestamp of API key creation + createdBy: + allOf: + - $ref: '#/components/schemas/TeamUser' + nullable: true + lastUsed: + type: string + format: date-time + description: Last time this API key was used + nullable: true + + CreatedTeamAPIKey: + required: + - id + - key + - mask + - name + - createdAt + properties: + id: + type: string + format: uuid + description: Identifier of the API key + key: + type: string + description: Raw value of the API key + mask: + $ref: '#/components/schemas/IdentifierMaskingDetails' + name: + type: string + description: Name of the API key + createdAt: + type: string + format: date-time + description: Timestamp of API key creation + createdBy: + allOf: + - $ref: '#/components/schemas/TeamUser' + nullable: true + lastUsed: + type: string + format: date-time + description: Last time this API key was used + nullable: true + + NewTeamAPIKey: + required: + - name + properties: + name: + type: string + description: Name of the API key + + UpdateTeamAPIKey: + required: + - name + properties: + name: + type: string + description: New name for the API key + + AssignedTemplateTags: + required: + - tags + - buildID + properties: + tags: + type: array + items: + type: string + description: Assigned tags of the template + buildID: + type: string + format: uuid + description: Identifier of the build associated with these tags + + TemplateTag: + required: + - tag + - buildID + - createdAt + properties: + tag: + type: string + description: The tag name + buildID: + type: string + format: uuid + description: Identifier of the build associated with this tag + createdAt: + type: string + format: date-time + description: Time when the tag was assigned + + AssignTemplateTagsRequest: + required: + - target + - tags + properties: + target: + type: string + description: Target template in "name:tag" format + tags: + description: Tags to assign to the template + type: array + items: + type: string + + DeleteTemplateTagsRequest: + required: + - name + - tags + properties: + name: + type: string + description: Name of the template + tags: + description: Tags to delete + type: array + items: + type: string + + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + description: Error code + message: + type: string + description: Error + + IdentifierMaskingDetails: + required: + - prefix + - valueLength + - maskedValuePrefix + - maskedValueSuffix + properties: + prefix: + type: string + description: Prefix that identifies the token or key type + valueLength: + type: integer + description: Length of the token or key + maskedValuePrefix: + type: string + description: Prefix used in masked version of the token or key + maskedValueSuffix: + type: string + description: Suffix used in masked version of the token or key + + Volume: + type: object + properties: + volumeID: + type: string + description: ID of the volume + name: + type: string + description: Name of the volume + required: + - volumeID + - name + + VolumeAndToken: + type: object + properties: + volumeID: + type: string + description: ID of the volume + name: + type: string + description: Name of the volume + token: + type: string + description: Auth token to use for interacting with volume content + required: + - volumeID + - name + - token + + NewVolume: + type: object + properties: + name: + type: string + description: Name of the volume + pattern: '^[a-zA-Z0-9_-]+$' + required: + - name + +tags: + - name: templates + - name: sandboxes + - name: auth + - name: access-tokens + - name: api-keys + - name: tags + - name: volumes + +paths: + /health: + get: + description: Health check + responses: + '204': + description: The service is healthy + '401': + $ref: '#/components/responses/401' + + /teams: + get: + description: List all teams + tags: [auth] + security: + - AccessTokenAuth: [] + - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] + responses: + '200': + description: Successfully returned all teams + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/Team' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /teams/{teamID}/metrics: + get: + description: Get metrics for the team + tags: [auth] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/teamID' + - in: query + name: start + schema: + type: integer + format: int64 + minimum: 0 + description: Unix timestamp for the start of the interval, in seconds, for which the metrics + - in: query + name: end + schema: + type: integer + format: int64 + minimum: 0 + description: Unix timestamp for the end of the interval, in seconds, for which the metrics + responses: + '200': + description: Successfully returned the team metrics + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TeamMetric' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '500': + $ref: '#/components/responses/500' + + /teams/{teamID}/metrics/max: + get: + description: Get the maximum metrics for the team in the given interval + tags: [auth] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/teamID' + - in: query + name: start + schema: + type: integer + format: int64 + minimum: 0 + description: Unix timestamp for the start of the interval, in seconds, for which the metrics + - in: query + name: end + schema: + type: integer + format: int64 + minimum: 0 + description: Unix timestamp for the end of the interval, in seconds, for which the metrics + - in: query + name: metric + required: true + schema: + type: string + enum: [concurrent_sandboxes, sandbox_start_rate] + description: Metric to retrieve the maximum value for + responses: + '200': + description: Successfully returned the team metrics + content: + application/json: + schema: + $ref: '#/components/schemas/MaxTeamMetric' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '500': + $ref: '#/components/responses/500' + + /sandboxes: + get: + description: List all running sandboxes + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - name: metadata + in: query + description: Metadata query used to filter the sandboxes (e.g. "user=abc&app=prod"). Each key and values must be URL encoded. + required: false + schema: + type: string + responses: + '200': + description: Successfully returned all running sandboxes + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/ListedSandbox' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '500': + $ref: '#/components/responses/500' + post: + description: Create a sandbox from the template + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewSandbox' + responses: + '201': + description: The sandbox was created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Sandbox' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '500': + $ref: '#/components/responses/500' + + /v2/sandboxes: + get: + description: List all sandboxes + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - name: metadata + in: query + description: Metadata query used to filter the sandboxes (e.g. "user=abc&app=prod"). Each key and values must be URL encoded. + required: false + schema: + type: string + - name: state + in: query + description: Filter sandboxes by one or more states + required: false + schema: + type: array + items: + $ref: '#/components/schemas/SandboxState' + style: form + explode: false + - $ref: '#/components/parameters/paginationNextToken' + - $ref: '#/components/parameters/paginationLimit' + responses: + '200': + description: Successfully returned all running sandboxes + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/ListedSandbox' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '500': + $ref: '#/components/responses/500' + + /sandboxes/metrics: + get: + description: List metrics for given sandboxes + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - name: sandbox_ids + in: query + required: true + description: Comma-separated list of sandbox IDs to get metrics for + explode: false + schema: + type: array + items: + type: string + maxItems: 100 + uniqueItems: true + responses: + '200': + description: Successfully returned all running sandboxes with metrics + content: + application/json: + schema: + $ref: '#/components/schemas/SandboxesWithMetrics' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '500': + $ref: '#/components/responses/500' + + /sandboxes/{sandboxID}/logs: + get: + description: Get sandbox logs. Use /v2/sandboxes/{sandboxID}/logs instead. + deprecated: true + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + - in: query + name: start + schema: + type: integer + format: int64 + minimum: 0 + description: Starting timestamp of the logs that should be returned in milliseconds + - in: query + name: limit + schema: + default: 1000 + format: int32 + minimum: 0 + type: integer + description: Maximum number of logs that should be returned + responses: + '200': + description: Successfully returned the sandbox logs + content: + application/json: + schema: + $ref: '#/components/schemas/SandboxLogs' + '404': + $ref: '#/components/responses/404' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /v2/sandboxes/{sandboxID}/logs: + get: + description: Get sandbox logs + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + - in: query + name: cursor + schema: + type: integer + format: int64 + minimum: 0 + description: Starting timestamp of the logs that should be returned in milliseconds + - in: query + name: limit + schema: + default: 1000 + type: integer + format: int32 + minimum: 0 + maximum: 1000 + description: Maximum number of logs that should be returned + - in: query + name: direction + schema: + $ref: '#/components/schemas/LogsDirection' + description: Direction of the logs that should be returned + - in: query + name: level + schema: + $ref: '#/components/schemas/LogLevel' + description: Minimum log level to return. Logs below this level are excluded + - in: query + name: search + schema: + type: string + maxLength: 256 + description: Case-sensitive substring match on log message content + responses: + '200': + description: Successfully returned the sandbox logs + content: + application/json: + schema: + $ref: '#/components/schemas/SandboxLogsV2Response' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /sandboxes/{sandboxID}: + get: + description: Get a sandbox by id + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + responses: + '200': + description: Successfully returned the sandbox + content: + application/json: + schema: + $ref: '#/components/schemas/SandboxDetail' + '404': + $ref: '#/components/responses/404' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + delete: + description: Kill a sandbox + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + responses: + '204': + description: The sandbox was killed successfully + '404': + $ref: '#/components/responses/404' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /sandboxes/{sandboxID}/metrics: + get: + description: Get sandbox metrics + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + - in: query + name: start + schema: + type: integer + format: int64 + minimum: 0 + description: Unix timestamp for the start of the interval, in seconds, for which the metrics + - in: query + name: end + schema: + type: integer + format: int64 + minimum: 0 + description: Unix timestamp for the end of the interval, in seconds, for which the metrics + + responses: + '200': + description: Successfully returned the sandbox metrics + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SandboxMetric' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + # TODO: Pause and resume might be exposed as POST /sandboxes/{sandboxID}/snapshot and then POST /sandboxes with specified snapshotting setup + /sandboxes/{sandboxID}/pause: + post: + description: Pause the sandbox + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/SandboxPauseRequest' + responses: + '204': + description: The sandbox was paused successfully and can be resumed + '409': + $ref: '#/components/responses/409' + '404': + $ref: '#/components/responses/404' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /sandboxes/{sandboxID}/resume: + post: + deprecated: true + description: Resume the sandbox + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResumedSandbox' + responses: + '201': + description: The sandbox was resumed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Sandbox' + '409': + $ref: '#/components/responses/409' + '404': + $ref: '#/components/responses/404' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /sandboxes/{sandboxID}/connect: + post: + description: Returns sandbox details. If the sandbox is paused, it will be resumed. TTL is only extended. + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConnectSandbox' + responses: + '200': + description: The sandbox was already running + content: + application/json: + schema: + $ref: '#/components/schemas/Sandbox' + '201': + description: The sandbox was resumed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Sandbox' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /sandboxes/{sandboxID}/timeout: + post: + description: Set the timeout for the sandbox. The sandbox will expire x seconds from the time of the request. Calling this method multiple times overwrites the TTL, each time using the current timestamp as the starting point to measure the timeout duration. + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + tags: [sandboxes] + requestBody: + content: + application/json: + schema: + type: object + required: + - timeout + properties: + timeout: + description: Timeout in seconds from the current time after which the sandbox should expire + type: integer + format: int32 + minimum: 0 + parameters: + - $ref: '#/components/parameters/sandboxID' + responses: + '204': + description: Successfully set the sandbox timeout + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /sandboxes/{sandboxID}/network: + put: + description: Update the network configuration for a running sandbox. Replaces the current egress rules with the provided configuration. Omitting field clears it. + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + tags: [sandboxes] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SandboxNetworkUpdateConfig' + parameters: + - $ref: '#/components/parameters/sandboxID' + responses: + '204': + description: Successfully updated the sandbox network configuration + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '409': + $ref: '#/components/responses/409' + '500': + $ref: '#/components/responses/500' + + /sandboxes/{sandboxID}/refreshes: + post: + description: Refresh the sandbox extending its time to live + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + tags: [sandboxes] + requestBody: + content: + application/json: + schema: + type: object + properties: + duration: + description: Duration for which the sandbox should be kept alive in seconds + type: integer + maximum: 3600 # 1 hour + minimum: 0 + parameters: + - $ref: '#/components/parameters/sandboxID' + responses: + '204': + description: Successfully refreshed the sandbox + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + + /sandboxes/{sandboxID}/snapshots: + post: + description: Create a persistent snapshot from the sandbox's current state. Snapshots can be used to create new sandboxes and persist beyond the original sandbox's lifetime. + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + responses: + '201': + description: Snapshot created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SnapshotInfo' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /snapshots: + get: + description: List all snapshots for the team + tags: [snapshots] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - name: sandboxID + in: query + required: false + schema: + type: string + description: Filter snapshots by source sandbox ID + - $ref: '#/components/parameters/paginationLimit' + - $ref: '#/components/parameters/paginationNextToken' + responses: + '200': + description: Successfully returned snapshots + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SnapshotInfo' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /v3/templates: + post: + description: Create a new template + tags: [templates] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateBuildRequestV3' + + responses: + '202': + description: The build was requested successfully + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateRequestResponseV3' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '500': + $ref: '#/components/responses/500' + + /v2/templates: + post: + description: Create a new template + deprecated: true + tags: [templates] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateBuildRequestV2' + + responses: + '202': + description: The build was requested successfully + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateLegacy' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /templates/{templateID}/files/{hash}: + get: + description: Get an upload link for a tar file containing build layer files + tags: [templates] + security: + - AccessTokenAuth: [] + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + - in: path + name: hash + required: true + schema: + type: string + description: Hash of the files + + responses: + '201': + description: The upload link where to upload the tar file + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateBuildFileUpload' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /templates: + get: + description: List all templates + tags: [templates] + security: + - ApiKeyAuth: [] + - AccessTokenAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - in: query + required: false + name: teamID + schema: + type: string + description: Identifier of the team + responses: + '200': + description: Successfully returned all templates + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/Template' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + post: + description: Create a new template + deprecated: true + tags: [templates] + security: + - AccessTokenAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateBuildRequest' + + responses: + '202': + description: The build was accepted + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateLegacy' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /templates/{templateID}: + get: + description: List all builds for a template + tags: [templates] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + - $ref: '#/components/parameters/paginationNextToken' + - $ref: '#/components/parameters/paginationLimit' + responses: + '200': + description: Successfully returned the template with its builds + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateWithBuilds' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + post: + description: Rebuild an template + deprecated: true + tags: [templates] + security: + - AccessTokenAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateBuildRequest' + + responses: + '202': + description: The build was accepted + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateLegacy' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + delete: + description: Delete a template + tags: [templates] + security: + - ApiKeyAuth: [] + - AccessTokenAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + responses: + '204': + description: The template was deleted successfully + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + patch: + description: Update template + deprecated: true + tags: [templates] + security: + - ApiKeyAuth: [] + - AccessTokenAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateUpdateRequest' + responses: + '200': + description: The template was updated successfully + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /templates/{templateID}/builds/{buildID}: + post: + description: Start the build + deprecated: true + tags: [templates] + security: + - AccessTokenAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + - $ref: '#/components/parameters/buildID' + responses: + '202': + description: The build has started + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /v2/templates/{templateID}/builds/{buildID}: + post: + description: Start the build + tags: [templates] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + - $ref: '#/components/parameters/buildID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateBuildStartV2' + responses: + '202': + description: The build has started + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /v2/templates/{templateID}: + patch: + description: Update template + tags: [templates] + security: + - ApiKeyAuth: [] + - AccessTokenAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateUpdateRequest' + responses: + '200': + description: The template was updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateUpdateResponse' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /templates/{templateID}/builds/{buildID}/status: + get: + description: Get template build info + tags: [templates] + security: + - AccessTokenAuth: [] + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + - $ref: '#/components/parameters/buildID' + - in: query + name: logsOffset + schema: + default: 0 + type: integer + format: int32 + minimum: 0 + description: Index of the starting build log that should be returned with the template + - in: query + name: limit + schema: + default: 100 + type: integer + format: int32 + minimum: 0 + maximum: 100 + description: Maximum number of logs that should be returned + - in: query + name: level + schema: + $ref: '#/components/schemas/LogLevel' + responses: + '200': + description: Successfully returned the template + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateBuildInfo' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /templates/{templateID}/builds/{buildID}/logs: + get: + description: Get template build logs + tags: [templates] + security: + - AccessTokenAuth: [] + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + - $ref: '#/components/parameters/buildID' + - in: query + name: cursor + schema: + type: integer + format: int64 + minimum: 0 + description: Starting timestamp of the logs that should be returned in milliseconds + - in: query + name: limit + schema: + default: 100 + type: integer + format: int32 + minimum: 0 + maximum: 100 + description: Maximum number of logs that should be returned + - in: query + name: direction + schema: + $ref: '#/components/schemas/LogsDirection' + - in: query + name: level + schema: + $ref: '#/components/schemas/LogLevel' + - in: query + name: source + schema: + $ref: '#/components/schemas/LogsSource' + description: Source of the logs that should be returned from + responses: + '200': + description: Successfully returned the template build logs + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateBuildLogsResponse' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /templates/tags: + post: + description: Assign tag(s) to a template build + tags: [tags] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AssignTemplateTagsRequest' + responses: + '201': + description: Tag assigned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/AssignedTemplateTags' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + delete: + description: Delete multiple tags from templates + tags: [tags] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteTemplateTagsRequest' + responses: + '204': + description: Tags deleted successfully + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /templates/{templateID}/tags: + get: + description: List all tags for a template + tags: [tags] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + responses: + '200': + description: Successfully returned the template tags + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TemplateTag' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /templates/aliases/{alias}: + get: + description: Check if template with given alias exists + tags: [templates] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - name: alias + in: path + required: true + schema: + type: string + description: Template alias + responses: + '200': + description: Successfully queried template by alias + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateAliasResponse' + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /nodes: + get: + description: List all nodes + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - in: query + name: clusterID + description: Identifier of the cluster + required: false + schema: + type: string + format: uuid + responses: + '200': + description: Successfully returned all nodes + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/Node' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /nodes/{nodeID}: + get: + description: Get node info + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - $ref: '#/components/parameters/nodeID' + - in: query + name: clusterID + description: Identifier of the cluster + required: false + schema: + type: string + format: uuid + responses: + '200': + description: Successfully returned the node + content: + application/json: + schema: + $ref: '#/components/schemas/NodeDetail' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + post: + description: Change status of a node + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - $ref: '#/components/parameters/nodeID' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/NodeStatusChange' + responses: + '204': + description: The node status was changed successfully + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /admin/teams/{teamID}/sandboxes/kill: + post: + summary: Kill all sandboxes for a team + description: Kills all sandboxes for the specified team + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - name: teamID + in: path + required: true + schema: + type: string + format: uuid + description: Team ID + responses: + '200': + description: Successfully killed sandboxes + content: + application/json: + schema: + $ref: '#/components/schemas/AdminSandboxKillResult' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /admin/teams/{teamID}/builds/cancel: + post: + summary: Cancel all builds for a team + description: Cancels all in-progress and pending builds for the specified team + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - name: teamID + in: path + required: true + schema: + type: string + format: uuid + description: Team ID + responses: + '200': + description: Successfully cancelled builds + content: + application/json: + schema: + $ref: '#/components/schemas/AdminBuildCancelResult' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /admin/teams/{teamID}/api-keys: + post: + summary: Create team API key as admin + description: Creates a team API key for internal service workflows. + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - name: teamID + in: path + required: true + schema: + type: string + format: uuid + description: Team ID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewTeamAPIKey' + responses: + '201': + description: Team API key created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CreatedTeamAPIKey' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /admin/teams/{teamID}/api-keys/{apiKeyID}: + delete: + summary: Delete team API key as admin + description: Deletes a team API key for internal service workflows. + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - name: teamID + in: path + required: true + schema: + type: string + format: uuid + description: Team ID + - $ref: '#/components/parameters/apiKeyID' + responses: + '204': + description: Team API key deleted successfully + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /access-tokens: + post: + description: Create a new access token + tags: [access-tokens] + security: + - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewAccessToken' + responses: + '201': + description: Access token created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CreatedAccessToken' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /access-tokens/{accessTokenID}: + delete: + description: Delete an access token + tags: [access-tokens] + security: + - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] + parameters: + - $ref: '#/components/parameters/accessTokenID' + responses: + '204': + description: Access token deleted successfully + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /api-keys: + get: + description: List all team API keys + tags: [api-keys] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + responses: + '200': + description: Successfully returned all team API keys + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TeamAPIKey' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + post: + description: Create a new team API key + tags: [api-keys] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewTeamAPIKey' + responses: + '201': + description: Team API key created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CreatedTeamAPIKey' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /api-keys/{apiKeyID}: + patch: + description: Update a team API key + tags: [api-keys] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/apiKeyID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTeamAPIKey' + responses: + '200': + description: Team API key updated successfully + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + delete: + description: Delete a team API key + tags: [api-keys] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/apiKeyID' + responses: + '204': + description: Team API key deleted successfully + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /volumes: + get: + description: List all team volumes + tags: [volumes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + responses: + '200': + description: Successfully listed all team volumes + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Volume' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + post: + description: Create a new team volume + tags: [volumes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewVolume' + responses: + '201': + description: Successfully created a new team volume + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeAndToken' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /volumes/{volumeID}: + get: + description: Get team volume info + tags: [volumes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/volumeID' + responses: + '200': + description: Successfully retrieved a team volume + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeAndToken' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + delete: + description: Delete a team volume + tags: [volumes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/volumeID' + responses: + '204': + description: Successfully deleted a team volume + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' diff --git a/spec/package.json b/spec/package.json new file mode 100644 index 0000000..47c7f6c --- /dev/null +++ b/spec/package.json @@ -0,0 +1,11 @@ +{ + "name": "@e2b/spec", + "private": true, + "scripts": { + "lint": "prettier --check openapi.yml envd/envd.yaml", + "format": "prettier --write openapi.yml envd/envd.yaml" + }, + "devDependencies": { + "prettier": "^3.3.3" + } +} diff --git a/spec/remove_extra_tags.py b/spec/remove_extra_tags.py new file mode 100644 index 0000000..7292a8e --- /dev/null +++ b/spec/remove_extra_tags.py @@ -0,0 +1,45 @@ +import os +import sys + +import yaml + +# Get directory of this script +directory = os.path.dirname(os.path.abspath(__file__)) +path = os.path.join(directory, "openapi.yml") + +# Load OpenAPI spec +with open(path, "r") as file: + spec = yaml.safe_load(file) + +tags_to_keep = sys.argv[1:] if len(sys.argv) > 1 else None +filtered_paths = {} + +for path, methods in spec.get("paths", {}).items(): + # Store path-level parameters if they exist + path_level_params = methods.get("parameters") + + for method, operation in methods.items(): + # Skip path-level parameters key as it's not a method + if method == "parameters": + continue + + if "tags" in operation: + for tag in tags_to_keep: + if tag in operation["tags"]: + if path in filtered_paths: + filtered_paths[path][method] = operation + else: + filtered_paths[path] = {method: operation} + # Add path-level parameters if they exist + if path_level_params: + filtered_paths[path]["parameters"] = path_level_params + break + + +# Create a new spec with only the filtered paths +filtered_spec = spec.copy() +filtered_spec["paths"] = filtered_paths + +# Save the filtered spec +with open(os.path.join(directory, "openapi_generated.yml"), "w") as file: + yaml.dump(filtered_spec, file) diff --git a/templates/base/e2b.Dockerfile b/templates/base/e2b.Dockerfile new file mode 100644 index 0000000..befa44d --- /dev/null +++ b/templates/base/e2b.Dockerfile @@ -0,0 +1,58 @@ +FROM python:3.11.6 + +# Inspired by https://github.com/nodejs/docker-node/blob/main/20/bookworm/Dockerfile +RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y \ + build-essential curl git util-linux \ + libbluetooth-dev tk-dev uuid-dev \ + gh; \ + rm -rf /var/lib/apt/lists/* + +RUN groupadd -r node && useradd -r -g node -s /bin/bash -m node + +ENV NODE_VERSION=20.9.0 + +RUN ARCH= && dpkgArch="$(dpkg --print-architecture)" \ + && case "${dpkgArch##*-}" in \ + amd64) ARCH='x64';; \ + ppc64el) ARCH='ppc64le';; \ + s390x) ARCH='s390x';; \ + arm64) ARCH='arm64';; \ + armhf) ARCH='armv7l';; \ + i386) ARCH='x86';; \ + *) echo "unsupported architecture"; exit 1 ;; \ + esac \ + # use pre-existing gpg directory, see https://github.com/nodejs/docker-node/pull/1895#issuecomment-1550389150 + && export GNUPGHOME="$(mktemp -d)" \ + # gpg keys listed at https://github.com/nodejs/node#release-keys + && set -ex \ + && for key in \ + 4ED778F539E3634C779C87C6D7062848A1AB005C \ + 141F07595B7B3FFE74309A937405533BE57C7D57 \ + 74F12602B6F1C4E913FAA37AD3A89613643B6201 \ + DD792F5973C6DE52C432CBDAC77ABFA00DDBF2B7 \ + 61FC681DFB92A079F1685E77973F295594EC4689 \ + 8FCCA13FEF1D0C2E91008E09770F7A9A5AE15600 \ + C4F0DFFF4E8C1A8236409D08E73BC641CC11F4C8 \ + 890C08DB8579162FEE0DF9DB8BEAB4DFCF555EF4 \ + C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C \ + 108F52B48DB57BB0CC439B2997B01419BD92F80A \ + A363A499291CBBC940DD62E41F10027AF002F8B0 \ + ; do \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$key" || \ + gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key" ; \ + done \ + && curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-$ARCH.tar.xz" \ + && curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \ + && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \ + && gpgconf --kill all \ + && rm -rf "$GNUPGHOME" \ + && grep " node-v$NODE_VERSION-linux-$ARCH.tar.xz\$" SHASUMS256.txt | sha256sum -c - \ + && tar -xJf "node-v$NODE_VERSION-linux-$ARCH.tar.xz" -C /usr/local --strip-components=1 --no-same-owner \ + && rm "node-v$NODE_VERSION-linux-$ARCH.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \ + && ln -s /usr/local/bin/node /usr/local/bin/nodejs \ + # smoke tests + && node --version \ + && npm --version + +RUN npm install -g yarn@1.22.19 \ + && yarn --version