diff --git a/.changeset/strange-hornets-judge.md b/.changeset/strange-hornets-judge.md deleted file mode 100644 index cb559f6abe..0000000000 --- a/.changeset/strange-hornets-judge.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@e2b/python-sdk': minor ---- - -Refactor connection config and parameters for all calls to API diff --git a/.github/workflows/generated_files.yml b/.github/workflows/generated_files.yml index ab57f57f12..73c50cd8a9 100644 --- a/.github/workflows/generated_files.yml +++ b/.github/workflows/generated_files.yml @@ -48,7 +48,7 @@ jobs: cache-to: type=gha,mode=max - name: Run codegen - run: make codegen + run: CODEGEN_IMAGE=codegen-env:latest make codegen - name: Check for uncommitted changes run: | diff --git a/Makefile b/Makefile index 95b8a89e72..1c7f74ab74 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,10 @@ .PHONY: codegen codegen: @echo "Generating SDK code from openapi and envd spec" - @docker run -v "$$(pwd):/workspace" $$(docker build -q -t codegen-env . -f codegen.Dockerfile) + @CODEGEN_IMAGE=$${CODEGEN_IMAGE:-$$(docker build -q -t codegen-env . -f codegen.Dockerfile)} ; \ + echo "Using codegen image: $$CODEGEN_IMAGE" \ + && docker run -v $$PWD:/workspace $$CODEGEN_IMAGE make generate + generate: generate-js generate-python generate-js: diff --git a/README.md b/README.md index d210cdc791..52ae998f69 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Python ```py from e2b_code_interpreter import Sandbox -with Sandbox() as sandbox: +with Sandbox.create() as sandbox: sandbox.run_code("x = 1") execution = sandbox.run_code("x+=1; x") print(execution.text) # outputs 2 diff --git a/codegen.Dockerfile b/codegen.Dockerfile index cb47be9cbf..9885f5f78b 100644 --- a/codegen.Dockerfile +++ b/codegen.Dockerfile @@ -32,8 +32,9 @@ COPY --from=0 /go /go # Add Go binary to PATH ENV PATH="/go/bin:${PATH}" -# Install Python deps -RUN pip install black==23.7.0 pyyaml==6.0.2 openapi-python-client==0.24.3 +# 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==23.7.0 pyyaml==6.0.2 e2b-openapi-python-client==0.26.2 # Install Node.js and npm RUN apt-get update && \ diff --git a/packages/cli/src/commands/sandbox/spawn.ts b/packages/cli/src/commands/sandbox/create.ts similarity index 84% rename from packages/cli/src/commands/sandbox/spawn.ts rename to packages/cli/src/commands/sandbox/create.ts index 584034985a..e600578782 100644 --- a/packages/cli/src/commands/sandbox/spawn.ts +++ b/packages/cli/src/commands/sandbox/create.ts @@ -10,15 +10,15 @@ import { getConfigPath, loadConfig } from '../../config' import fs from 'fs' import { configOption, pathOption } from '../../options' -export const spawnCommand = new commander.Command('spawn') - .description('spawn sandbox and connect terminal to it') +export const createCommand = new commander.Command('create') + .description('create sandbox and connect terminal to it') .argument( '[template]', - `spawn and connect to sandbox specified by ${asBold('[template]')}`, + `create and connect to sandbox specified by ${asBold('[template]')}` ) .addOption(pathOption) .addOption(configOption) - .alias('sp') + .alias('cr') .action( async ( template: string | undefined, @@ -26,7 +26,7 @@ export const spawnCommand = new commander.Command('spawn') name?: string path?: string config?: string - }, + } ) => { try { const apiKey = ensureAPIKey() @@ -49,15 +49,15 @@ export const spawnCommand = new commander.Command('spawn') ? [config.template_name] : undefined, }, - relativeConfigPath, - )}`, + relativeConfigPath + )}` ) templateID = config.template_id } if (!templateID) { console.error( - 'You need to specify sandbox template ID or path to sandbox template config', + 'You need to specify sandbox template ID or path to sandbox template config' ) process.exit(1) } @@ -68,7 +68,7 @@ export const spawnCommand = new commander.Command('spawn') console.error(err) process.exit(1) } - }, + } ) export async function connectSandbox({ @@ -87,8 +87,8 @@ export async function connectSandbox({ console.log( `Terminal connecting to template ${asFormattedSandboxTemplate( - template, - )} with sandbox ID ${asBold(`${sandbox.sandboxId}`)}`, + template + )} with sandbox ID ${asBold(`${sandbox.sandboxId}`)}` ) try { await spawnConnectedTerminal(sandbox) @@ -97,8 +97,8 @@ export async function connectSandbox({ await sandbox.kill() console.log( `Closing terminal connection to template ${asFormattedSandboxTemplate( - template, - )} with sandbox ID ${asBold(`${sandbox.sandboxId}`)}`, + template + )} with sandbox ID ${asBold(`${sandbox.sandboxId}`)}` ) } } diff --git a/packages/cli/src/commands/sandbox/index.ts b/packages/cli/src/commands/sandbox/index.ts index 3c61df8481..a911cdcba5 100644 --- a/packages/cli/src/commands/sandbox/index.ts +++ b/packages/cli/src/commands/sandbox/index.ts @@ -3,7 +3,7 @@ import * as commander from 'commander' import { connectCommand } from './connect' import { listCommand } from './list' import { killCommand } from './kill' -import { spawnCommand } from './spawn' +import { createCommand } from './create' import { logsCommand } from './logs' import { metricsCommand } from './metrics' @@ -13,6 +13,6 @@ export const sandboxCommand = new commander.Command('sandbox') .addCommand(connectCommand) .addCommand(listCommand) .addCommand(killCommand) - .addCommand(spawnCommand) + .addCommand(createCommand) .addCommand(logsCommand) .addCommand(metricsCommand) diff --git a/packages/cli/src/commands/sandbox/kill.ts b/packages/cli/src/commands/sandbox/kill.ts index 2783659268..4f11041327 100644 --- a/packages/cli/src/commands/sandbox/kill.ts +++ b/packages/cli/src/commands/sandbox/kill.ts @@ -3,6 +3,7 @@ import * as commander from 'commander' import { ensureAPIKey } from 'src/api' import { asBold } from 'src/utils/format' import * as e2b from 'e2b' +import { listSandboxes } from './list' async function killSandbox(sandboxID: string, apiKey: string) { const killed = await e2b.Sandbox.kill(sandboxID, { apiKey }) @@ -17,7 +18,7 @@ export const killCommand = new commander.Command('kill') .description('kill sandbox') .argument( '[sandboxID]', - `kill the sandbox specified by ${asBold('[sandboxID]')}`, + `kill the sandbox specified by ${asBold('[sandboxID]')}` ) .alias('kl') .option('-a, --all', 'kill all running sandboxes') @@ -28,8 +29,8 @@ export const killCommand = new commander.Command('kill') if (!sandboxID && !all) { console.error( `You need to specify ${asBold('[sandboxID]')} or use ${asBold( - '-a/--all', - )} flag`, + '-a/--all' + )} flag` ) process.exit(1) } @@ -37,22 +38,23 @@ export const killCommand = new commander.Command('kill') if (all && sandboxID) { console.error( `You cannot use ${asBold('-a/--all')} flag while specifying ${asBold( - '[sandboxID]', - )}`, + '[sandboxID]' + )}` ) process.exit(1) } if (all) { - const sandboxes = await e2b.Sandbox.list({ apiKey }) - + const sandboxes = await listSandboxes({ + state: ['running'], + }) if (sandboxes.length === 0) { console.log('No running sandboxes') process.exit(0) } await Promise.all( - sandboxes.map((sandbox) => killSandbox(sandbox.sandboxId, apiKey)), + sandboxes.map((sandbox) => killSandbox(sandbox.sandboxID, apiKey)) ) } else { await killSandbox(sandboxID, apiKey) diff --git a/packages/cli/src/commands/sandbox/list.ts b/packages/cli/src/commands/sandbox/list.ts index b763cfcd82..e1d929080c 100644 --- a/packages/cli/src/commands/sandbox/list.ts +++ b/packages/cli/src/commands/sandbox/list.ts @@ -1,6 +1,6 @@ import * as tablePrinter from 'console-table-printer' import * as commander from 'commander' -import * as e2b from 'e2b' +import { components } from 'e2b' import { client, connectionConfig, ensureAPIKey } from 'src/api' import { handleE2BRequestError } from '../../utils/errors' @@ -8,12 +8,31 @@ import { handleE2BRequestError } from '../../utils/errors' export const listCommand = new commander.Command('list') .description('list all running sandboxes') .alias('ls') - .action(async () => { + .option( + '-s, --state ', + 'filter by state, eg. running, stopped', + (value) => value.split(',') + ) + .option( + '-m, --metadata ', + 'filter by metadata, eg. key1=value1', + (value) => value.replace(/,/g, '&') + ) + .option( + '-l, --limit ', + 'limit the number of sandboxes returned', + (value) => parseInt(value) + ) + .action(async (options) => { try { - const sandboxes = await listSandboxes() + const sandboxes = await listSandboxes({ + limit: options.limit, + state: options.state, + metadata: options.metadata, + }) if (!sandboxes?.length) { - console.log('No running sandboxes.') + console.log('No sandboxes found') } else { const table = new tablePrinter.Table({ title: 'Running sandboxes', @@ -28,6 +47,7 @@ export const listCommand = new commander.Command('list') { name: 'alias', 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: 'metadata', alignment: 'left', title: 'Metadata' }, @@ -39,6 +59,8 @@ export const listCommand = new commander.Command('list') sandboxID: sandbox.sandboxID, 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( @@ -81,15 +103,51 @@ export const listCommand = new commander.Command('list') } }) -export async function listSandboxes(): Promise< - e2b.components['schemas']['ListedSandbox'][] +type ListSandboxesOptions = { + limit?: number + state?: components['schemas']['SandboxState'][] + metadata?: string +} + +export async function listSandboxes({ + limit, + state, + metadata, +}: ListSandboxesOptions = {}): Promise< + components['schemas']['ListedSandbox'][] > { ensureAPIKey() const signal = connectionConfig.getSignal() - const res = await client.api.GET('/sandboxes', { signal }) - handleE2BRequestError(res, 'Error getting running sandboxes') + let hasNext = true + let nextToken: string | undefined + let remainingLimit: number | undefined = limit + + const sandboxes: components['schemas']['ListedSandbox'][] = [] + + while (hasNext && (!limit || (remainingLimit && remainingLimit > 0))) { + const res = await client.api.GET('/v2/sandboxes', { + params: { + query: { + state, + metadata, + nextToken, + limit: remainingLimit, + }, + }, + signal, + }) + + handleE2BRequestError(res, 'Error getting running sandboxes') + + nextToken = res.response.headers.get('x-next-token') || undefined + hasNext = !!nextToken + sandboxes.push(...res.data) + if (limit && remainingLimit) { + remainingLimit -= res.data.length + } + } - return res.data + return sandboxes } diff --git a/packages/cli/src/commands/sandbox/logs.ts b/packages/cli/src/commands/sandbox/logs.ts index 4c5ee90f31..8d83c851da 100644 --- a/packages/cli/src/commands/sandbox/logs.ts +++ b/packages/cli/src/commands/sandbox/logs.ts @@ -104,7 +104,7 @@ export const logsCommand = new commander.Command('logs') console.log(`\nLogs for sandbox ${asBold(sandboxID)}:`) } - const isRunningPromise = listSandboxes() + const isRunningPromise = listSandboxes({ state: ['running'] }) .then((r) => r.find((s) => s.sandboxID === getShortID(sandboxID))) .then((s) => !!s) diff --git a/packages/js-sdk/src/api/index.ts b/packages/js-sdk/src/api/index.ts index 4452bd8319..bc3fcc121b 100644 --- a/packages/js-sdk/src/api/index.ts +++ b/packages/js-sdk/src/api/index.ts @@ -61,6 +61,12 @@ class ApiClient { }), ...config.headers, }, + querySerializer: { + array: { + style: 'form', + explode: false, + }, + }, }) if (config.logger) { diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index 1cc4f698c3..428d4a0112 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -1,7 +1,8 @@ import { Logger } from './logs' import { getEnvVar, version } from './api/metadata' -const REQUEST_TIMEOUT_MS = 60_000 // 60 seconds +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' diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 31090d8d54..942d2a4370 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -33,7 +33,18 @@ export type { PtyOutput, CommandHandle, } from './sandbox/commands/commandHandle' -export type { SandboxApiOpts, SandboxCreateOpts } from './sandbox/sandboxApi' +export type { + SandboxInfo, + SandboxMetrics, + SandboxOpts, + SandboxApiOpts, + SandboxConnectOpts, + SandboxBetaCreateOpts, + SandboxMetricsOpts, + SandboxState, + SandboxListOpts, + SandboxPaginator, +} from './sandbox/sandboxApi' export type { ProcessInfo, @@ -44,8 +55,6 @@ export type { Pty, } from './sandbox/commands' -export type { SandboxOpts } from './sandbox' -export type { SandboxInfo, SandboxMetrics } from './sandbox/sandboxApi' export { Sandbox } import { Sandbox } from './sandbox' export default Sandbox diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index 38ef9cbd5d..43c1618851 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -3,6 +3,7 @@ import { createConnectTransport } from '@connectrpc/connect-web' import { ConnectionConfig, ConnectionOpts, + DEFAULT_SANDBOX_TIMEOUT_MS, defaultUsername, Username, } from '../connectionConfig' @@ -10,55 +11,19 @@ import { EnvdApiClient, handleEnvdApiError } from '../envd/api' import { createRpcLogger } from '../logs' import { Commands, Pty } from './commands' import { Filesystem } from './filesystem' -import { SandboxApi, SandboxMetricsOpts } from './sandboxApi' +import { + SandboxOpts, + SandboxConnectOpts, + SandboxMetricsOpts, + SandboxApi, + SandboxListOpts, + SandboxPaginator, + SandboxBetaCreateOpts, +} from './sandboxApi' import { getSignature } from './signature' import { compareVersions } from 'compare-versions' import { SandboxError } from '../errors' -/** - * Options for creating a new Sandbox. - */ -export interface SandboxOpts extends ConnectionOpts { - /** - * 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 false - */ - secure?: boolean - - /** - * Allow sandbox to access the internet - * - * @default true - */ - allowInternetAccess?: boolean -} - /** * Options for sandbox upload/download URL generation. */ @@ -98,7 +63,7 @@ export interface SandboxUrlOpts { */ export class Sandbox extends SandboxApi { protected static readonly defaultTemplate: string = 'base' - protected static readonly defaultSandboxTimeoutMs = 300_000 + protected static readonly defaultSandboxTimeoutMs = DEFAULT_SANDBOX_TIMEOUT_MS /** * Module for interacting with the sandbox filesystem @@ -139,7 +104,7 @@ export class Sandbox extends SandboxApi { * @access protected */ constructor( - opts: Omit & { + opts: SandboxConnectOpts & { sandboxId: string sandboxDomain?: string envdVersion?: string @@ -154,9 +119,8 @@ export class Sandbox extends SandboxApi { this.sandboxDomain = opts.sandboxDomain ?? this.connectionConfig.domain this.envdAccessToken = opts.envdAccessToken - this.envdApiUrl = `${ - this.connectionConfig.debug ? 'http' : 'https' - }://${this.getHost(this.envdPort)}` + this.envdApiUrl = `${this.connectionConfig.debug ? 'http' : 'https' + }://${this.getHost(this.envdPort)}` const rpcTransport = createConnectTransport({ baseUrl: this.envdApiUrl, @@ -209,6 +173,17 @@ export class Sandbox extends SandboxApi { this.pty = new Pty(rpcTransport, this.connectionConfig) } + /** + * List all sandboxes. + * + * @param opts connection options. + * + * @returns paginator for listing sandboxes. + */ + static list(opts?: SandboxListOpts): SandboxPaginator { + return new SandboxPaginator(opts) + } + /** * Create a new sandbox from the default `base` sandbox template. * @@ -220,7 +195,7 @@ export class Sandbox extends SandboxApi { * ```ts * const sandbox = await Sandbox.create() * ``` - * @constructs Sandbox + * @constructs {@link Sandbox} */ static async create( this: S, @@ -239,7 +214,7 @@ export class Sandbox extends SandboxApi { * ```ts * const sandbox = await Sandbox.create('') * ``` - * @constructs Sandbox + * @constructs {@link Sandbox} */ static async create( this: S, @@ -262,24 +237,95 @@ export class Sandbox extends SandboxApi { sandboxId: 'debug_sandbox_id', ...config, }) as InstanceType - } else { - const sandbox = await this.createSandbox( - template, - sandboxOpts?.timeoutMs ?? this.defaultSandboxTimeoutMs, - sandboxOpts - ) - return new this({ ...sandbox, ...config }) as InstanceType } + + const sandbox = await SandboxApi.createSandbox( + template, + sandboxOpts?.timeoutMs ?? this.defaultSandboxTimeoutMs, + sandboxOpts + ) + + return new this({ ...sandbox, ...config }) as InstanceType + } + + /** + * @beta This feature is in beta and may change in the future. + * + * 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.betaCreate() + * ``` + * @constructs {@link Sandbox} + */ + static async betaCreate( + this: S, + opts?: SandboxBetaCreateOpts + ): Promise> + + /** + * @beta This feature is in beta and may change in the future. + * + * 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.betaCreate('') + * ``` + * @constructs {@link Sandbox} + */ + static async betaCreate( + this: S, + template: string, + opts?: SandboxBetaCreateOpts + ): Promise> + static async betaCreate( + this: S, + templateOrOpts?: SandboxBetaCreateOpts | string, + opts?: SandboxBetaCreateOpts + ): Promise> { + const { template, sandboxOpts } = + typeof templateOrOpts === 'string' + ? { template: templateOrOpts, sandboxOpts: opts } + : { template: this.defaultTemplate, sandboxOpts: templateOrOpts } + + const config = new ConnectionConfig(sandboxOpts) + if (config.debug) { + return new this({ + sandboxId: 'debug_sandbox_id', + ...config, + }) as InstanceType + } + + const sandbox = await SandboxApi.createSandbox( + template, + sandboxOpts?.timeoutMs ?? this.defaultSandboxTimeoutMs, + sandboxOpts + ) + + return new this({ ...sandbox, ...config }) as InstanceType } /** - * Connect to an existing 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 sandbox instance for the existing sandbox. + * @returns A running sandbox instance * * @example * ```ts @@ -293,10 +339,25 @@ export class Sandbox extends SandboxApi { static async connect( this: S, sandboxId: string, - opts?: Omit + opts?: SandboxConnectOpts ): Promise> { + try { + await SandboxApi.setTimeout( + sandboxId, + opts?.timeoutMs || DEFAULT_SANDBOX_TIMEOUT_MS, + opts + ) + } catch (e) { + if (e instanceof SandboxError) { + await SandboxApi.resumeSandbox(sandboxId, opts) + } else { + throw e + } + } + + const info = await SandboxApi.getFullInfo(sandboxId, opts) + const config = new ConnectionConfig(opts) - const info = await this.getInfo(sandboxId, opts) return new this({ sandboxId, @@ -307,6 +368,39 @@ export class Sandbox extends SandboxApi { }) 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?: SandboxBetaCreateOpts): Promise { + try { + await SandboxApi.setTimeout( + this.sandboxId, + opts?.timeoutMs || DEFAULT_SANDBOX_TIMEOUT_MS, + opts + ) + } catch (e) { + await SandboxApi.resumeSandbox(this.sandboxId, 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. @@ -386,7 +480,7 @@ export class Sandbox extends SandboxApi { return } - await Sandbox.setTimeout(this.sandboxId, timeoutMs, { + await SandboxApi.setTimeout(this.sandboxId, timeoutMs, { ...this.connectionConfig, ...opts, }) @@ -403,7 +497,20 @@ export class Sandbox extends SandboxApi { return } - await Sandbox.kill(this.sandboxId, { ...this.connectionConfig, ...opts }) + await SandboxApi.kill(this.sandboxId, { ...this.connectionConfig, ...opts }) + } + + /** + * @beta This feature is in beta and may change in the future. + * + * Pause a sandbox by its ID. + * + * @param opts connection options. + * + * @returns sandbox ID that can be used to resume the sandbox. + */ + async betaPause(opts?: ConnectionOpts): Promise { + return await SandboxApi.betaPause(this.sandboxId, opts) } /** @@ -424,7 +531,7 @@ export class Sandbox extends SandboxApi { if (!useSignature && opts.useSignatureExpiration != undefined) { throw new Error( - 'Signature expiration can be used only when sandbox is created as secured.' + 'Signature expiration can be used only when sandbox is created as secured.' ) } @@ -505,7 +612,7 @@ export class Sandbox extends SandboxApi { * @returns information about the sandbox */ async getInfo(opts?: Pick) { - return await Sandbox.getInfo(this.sandboxId, { + return await SandboxApi.getInfo(this.sandboxId, { ...this.connectionConfig, ...opts, }) @@ -518,12 +625,12 @@ export class Sandbox extends SandboxApi { * * @returns List of sandbox metrics containing CPU, memory and disk usage information. */ - async getMetrics(opts?: Pick) { + async getMetrics(opts?: SandboxMetricsOpts) { if (this.envdApi.version) { if (compareVersions(this.envdApi.version, '0.1.5') < 0) { throw new SandboxError( 'You need to update the template to use the new SDK. ' + - 'You can do this by running `e2b template build` in the directory with the template.' + 'You can do this by running `e2b template build` in the directory with the template.' ) } @@ -534,7 +641,7 @@ export class Sandbox extends SandboxApi { } } - return await Sandbox.getMetrics(this.sandboxId, { + return await SandboxApi.getMetrics(this.sandboxId, { ...this.connectionConfig, ...opts, }) diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 7dac6d8231..c6bf851093 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -1,7 +1,12 @@ import { ApiClient, components, handleApiError } from '../api' -import { ConnectionConfig, ConnectionOpts } from '../connectionConfig' +import { + ConnectionConfig, + ConnectionOpts, + DEFAULT_SANDBOX_TIMEOUT_MS, +} from '../connectionConfig' import { compareVersions } from 'compare-versions' -import { TemplateError } from '../errors' +import { NotFoundError, TemplateError } from '../errors' +import { timeoutToSeconds } from '../utils' /** * Options for request to the Sandbox API. @@ -15,35 +20,92 @@ export interface SandboxApiOpts > {} /** - * Options for create sandbox request. + * Options for creating a new Sandbox. */ -export interface SandboxCreateOpts extends SandboxApiOpts { +export interface SandboxOpts extends ConnectionOpts { /** - * Custom metadata for the sandbox + * Custom metadata for the sandbox. + * + * @default {} */ metadata?: Record /** - * Custom environment variables for the sandbox + * 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 /** - * Envd is secured with access token and cannot be used without it + * 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 false */ secure?: boolean /** - * Allow sandbox to access the internet, defaults to `true`. + * Allow sandbox to access the internet + * + * @default true */ allowInternetAccess?: boolean } +export type SandboxBetaCreateOpts = SandboxOpts & { + /** + * Automatically pause the sandbox after the timeout expires. + * @default false + */ + autoPause?: boolean +} + +/** + * Options for connecting to a Sandbox. + */ +export type SandboxConnectOpts = Omit + +/** + * State of the sandbox. + */ +export type SandboxState = 'running' | 'paused' + export interface SandboxListOpts extends SandboxApiOpts { /** * 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 } + query?: { + metadata?: Record + /** + * Filter the list of sandboxes by state. + * @default ['running', 'paused'] + */ + state?: Array + } + + /** + * Number of sandboxes to return per page. + * + * @default 1000 + */ + limit?: number + + /** + * Token to the next page. + */ + nextToken?: string } export interface SandboxMetricsOpts extends SandboxApiOpts { @@ -66,11 +128,6 @@ export interface SandboxInfo { */ sandboxId: string - /** - * Domain where the sandbox is hosted. - */ - sandboxDomain?: string - /** * Template ID. */ @@ -81,16 +138,6 @@ export interface SandboxInfo { */ name?: string - /** - * Envd access token. - */ - envdAccessToken?: string - - /** - * Envd version. - */ - envdVersion?: string - /** * Saved sandbox metadata. */ @@ -105,34 +152,13 @@ export interface SandboxInfo { * Sandbox expiration date. */ endAt: Date -} - -export interface ListedSandbox { - /** - * Sandbox ID. - */ - sandboxId: string - - /** - * Template ID alias. - */ - alias?: string - - /** - * Template ID. - */ - templateId: string - - /** - * Client ID. - * @deprecated - */ - clientId: string /** * Sandbox state. + * + * @string can be `running` or `paused` */ - state: 'running' | 'paused' + state: SandboxState /** * Sandbox CPU count. @@ -140,24 +166,14 @@ export interface ListedSandbox { cpuCount: number /** - * Sandbox Memory size in MB. + * Sandbox Memory size in MiB. */ memoryMB: number /** - * Saved sandbox metadata. - */ - metadata?: Record - - /** - * Sandbox expected end time. - */ - endAt: Date - - /** - * Sandbox start time. + * Envd version. */ - startedAt: Date + envdVersion: string } /** @@ -239,58 +255,6 @@ export class SandboxApi { return true } - /** - * List all running sandboxes. - * - * @param opts connection options. - * - * @returns list of running sandboxes. - */ - static async list(opts?: SandboxListOpts): Promise { - const config = new ConnectionConfig(opts) - const client = new ApiClient(config) - - let metadata = undefined - if (opts?.query) { - if (opts.query.metadata) { - const encodedPairs: Record = Object.fromEntries( - Object.entries(opts.query.metadata).map(([key, value]) => [ - encodeURIComponent(key), - encodeURIComponent(value), - ]) - ) - metadata = new URLSearchParams(encodedPairs).toString() - } - } - - const res = await client.api.GET('/sandboxes', { - params: { - query: { metadata }, - }, - signal: config.getSignal(opts?.requestTimeoutMs), - }) - - const err = handleApiError(res) - if (err) { - throw err - } - - return ( - res.data?.map((sandbox: components['schemas']['ListedSandbox']) => ({ - sandboxId: sandbox.sandboxID, - templateId: sandbox.templateID, - clientId: sandbox.clientID, - state: sandbox.state, - cpuCount: sandbox.cpuCount, - memoryMB: sandbox.memoryMB, - alias: sandbox.alias, - metadata: sandbox.metadata, - startedAt: new Date(sandbox.startedAt), - endAt: new Date(sandbox.endAt), - })) ?? [] - ) - } - /** * Get sandbox information like sandbox ID, template, metadata, started at/end at date. * @@ -303,38 +267,11 @@ export class SandboxApi { 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), - }) - - const err = handleApiError(res) - if (err) { - throw err - } + const fullInfo = await this.getFullInfo(sandboxId, opts) + delete fullInfo.envdAccessToken + delete fullInfo.sandboxDomain - if (!res.data) { - throw new Error('Sandbox not found') - } - - return { - sandboxId: res.data.sandboxID, - sandboxDomain: res.data!.domain || undefined, - templateId: res.data.templateID, - ...(res.data.alias && { name: res.data.alias }), - metadata: res.data.metadata ?? {}, - envdVersion: res.data.envdVersion, - envdAccessToken: res.data.envdAccessToken, - startedAt: new Date(res.data.startedAt), - endAt: new Date(res.data.endAt), - } + return fullInfo } /** @@ -408,21 +345,100 @@ export class SandboxApi { }, }, body: { - timeout: this.timeoutToSeconds(timeoutMs), + timeout: timeoutToSeconds(timeoutMs), + }, + signal: config.getSignal(opts?.requestTimeoutMs), + }) + + const err = handleApiError(res) + if (err) { + throw err + } + } + + static async getFullInfo(sandboxId: string, opts?: SandboxApiOpts) { + 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), + }) + + 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 ?? {}, + envdVersion: res.data.envdVersion, + envdAccessToken: res.data.envdAccessToken, + startedAt: new Date(res.data.startedAt), + endAt: new Date(res.data.endAt), + state: res.data.state, + cpuCount: res.data.cpuCount, + memoryMB: res.data.memoryMB, + sandboxDomain: res.data.domain || undefined, + } + } + + /** + * Pause the sandbox specified by sandbox ID. + * + * @param sandboxId sandbox ID. + * @param opts connection options. + * + * @returns `true` if the sandbox got paused, `false` if the sandbox was already paused. + */ + static async betaPause( + sandboxId: string, + opts?: SandboxApiOpts + ): Promise { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.POST('/sandboxes/{sandboxID}/pause', { + params: { + path: { + sandboxID: sandboxId, + }, }, signal: config.getSignal(opts?.requestTimeoutMs), }) + if (res.error?.code === 404) { + throw new NotFoundError(`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 } protected static async createSandbox( template: string, timeoutMs: number, - opts?: SandboxCreateOpts + opts?: SandboxBetaCreateOpts ): Promise<{ sandboxId: string sandboxDomain?: string @@ -434,11 +450,11 @@ export class SandboxApi { const res = await client.api.POST('/sandboxes', { body: { - autoPause: false, + autoPause: opts?.autoPause ?? false, templateID: template, metadata: opts?.metadata, envVars: opts?.envs, - timeout: this.timeoutToSeconds(timeoutMs), + timeout: timeoutToSeconds(timeoutMs), secure: opts?.secure, allow_internet_access: opts?.allowInternetAccess ?? true, }, @@ -466,7 +482,152 @@ export class SandboxApi { } } - private static timeoutToSeconds(timeout: number): number { - return Math.ceil(timeout / 1000) + protected static async resumeSandbox( + sandboxId: string, + opts?: SandboxConnectOpts + ): Promise { + 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}/resume', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + body: { + autoPause: false, + timeout: timeoutToSeconds(timeoutMs), + }, + signal: config.getSignal(opts?.requestTimeoutMs), + }) + + if (res.error?.code === 404) { + throw new NotFoundError(`Paused sandbox ${sandboxId} not found`) + } + + if (res.error?.code === 409) { + // Sandbox is already running + return false + } + + const err = handleApiError(res) + if (err) { + throw err + } + + return true + } +} + +/** + * Paginator for listing sandboxes. + * + * @example + * ```ts + * const paginator = Sandbox.list() + * + * while (paginator.hasNext) { + * const sandboxes = await paginator.nextItems() + * console.log(sandboxes) + * } + * ``` + */ +export class SandboxPaginator { + private _hasNext: boolean + private _nextToken?: string + + private readonly config: ConnectionConfig + private client: ApiClient + + private query: SandboxListOpts['query'] + private readonly limit?: number + + constructor(opts?: SandboxListOpts) { + this.config = new ConnectionConfig(opts) + this.client = new ApiClient(this.config) + + this._hasNext = true + this._nextToken = opts?.nextToken + + this.query = opts?.query + this.limit = opts?.limit + } + + /** + * 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 + } + + /** + * Get the next page of sandboxes. + * + * @throws Error if there are no more items to fetch. Call this method only if `hasNext` is `true`. + * + * @returns List of sandboxes + */ + async nextItems(): 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 res = await this.client.api.GET('/v2/sandboxes', { + params: { + query: { + metadata, + state: this.query?.state, + limit: this.limit, + nextToken: this.nextToken, + }, + }, + // requestTimeoutMs is already passed here via the connectionConfig. + signal: this.config.getSignal(), + }) + + const err = handleApiError(res) + if (err) { + throw err + } + + this._nextToken = res.response.headers.get('x-next-token') || undefined + this._hasNext = !!this._nextToken + + 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, + }) + ) } } diff --git a/packages/js-sdk/src/utils.ts b/packages/js-sdk/src/utils.ts index a5ab30c2d0..c7adb75c7f 100644 --- a/packages/js-sdk/src/utils.ts +++ b/packages/js-sdk/src/utils.ts @@ -14,3 +14,7 @@ export async function sha256(data: string): Promise { const hash = createHash('sha256').update(data, 'utf8').digest() return hash.toString('base64') } + +export function timeoutToSeconds(timeout: number): number { + return Math.ceil(timeout / 1000) +} diff --git a/packages/js-sdk/tests/api/kill.test.ts b/packages/js-sdk/tests/api/kill.test.ts index 2e928befea..1d57baa4e5 100644 --- a/packages/js-sdk/tests/api/kill.test.ts +++ b/packages/js-sdk/tests/api/kill.test.ts @@ -3,12 +3,18 @@ import { expect } from 'vitest' import { sandboxTest, isDebug } from '../setup.js' import { Sandbox } from '../../src' -sandboxTest.skipIf(isDebug)('kill existing sandbox', async ({ sandbox }) => { - await Sandbox.kill(sandbox.sandboxId) +sandboxTest.skipIf(isDebug)( + 'kill existing sandbox', + async ({ sandbox, sandboxTestId }) => { + await Sandbox.kill(sandbox.sandboxId) - const list = await Sandbox.list() - expect(list.map(s => s.sandboxId)).not.toContain(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('non-existing-sandbox')).resolves.toBe(false) diff --git a/packages/js-sdk/tests/api/list.test.ts b/packages/js-sdk/tests/api/list.test.ts index b59d71836b..2f262e7212 100644 --- a/packages/js-sdk/tests/api/list.test.ts +++ b/packages/js-sdk/tests/api/list.test.ts @@ -1,40 +1,446 @@ import { assert } from 'vitest' -import { Sandbox } from '../../src' -import { isDebug, sandboxTest, template } from '../setup.js' - -sandboxTest.skipIf(isDebug)('list sandboxes', async ({ sandbox }) => { - const sandboxes = await Sandbox.list() - assert.isAtLeast(sandboxes.length, 1) - assert.include( - sandboxes.map((s) => s.sandboxId), - sandbox.sandboxId - ) -}) +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 metadata filter', async () => { +sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => { const uniqueId = Date.now().toString() - // Create an extra sandbox with a uniqueId - const extraSbx = await Sandbox.create(template) + const extraSbx = await Sandbox.create({ metadata: { uniqueId } }) + try { - const sbx = await Sandbox.create(template, { metadata: { uniqueId: uniqueId } }) + 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 pausedSandboxId = extraSbx.sandboxId.split('-')[0] + const found = sandboxes.some( + (s) => s.sandboxId.startsWith(pausedSandboxId) && 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 }) => { + const sandboxId = sandbox.sandboxId.split('-')[0] + await sandbox.betaPause() + + // Create extra paused sandbox + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + await extraSbx.betaPause() + const extraSbxId = extraSbx.sandboxId.split('-')[0] + try { - const sandboxes = await Sandbox.list({ query: { metadata: { uniqueId } } }) + // 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].sandboxId, sbx.sandboxId) + assert.equal(sandboxes[0].state, 'paused') + assert.isTrue(paginator.hasNext) + assert.notEqual(paginator.nextToken, undefined) + assert.equal(sandboxes[0].sandboxId.startsWith(extraSbxId), true) + + // 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.startsWith(sandboxId), true) } finally { - await sbx.kill() + await extraSbx.kill() } + } +) + +sandboxTest.skipIf(isDebug)( + 'paginate running and paused sandboxes', + async ({ sandbox, sandboxTestId }) => { + // Create extra sandbox + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + const extraSbxId = extraSbx.sandboxId.split('-')[0] + + // 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.startsWith(extraSbxId), true) + + // 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 = Date.now().toString() + 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 sandboxes empty filter', async ({ sandbox }) => { - const sandboxes = await Sandbox.list() - assert.isAtLeast(sandboxes.length, 1) - assert.include( - sandboxes.map((s) => s.sandboxId), - sandbox.sandboxId - ) -}) +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 pausedSandboxId = extraSbx.sandboxId.split('-')[0] + const found = sandboxes.some( + (s) => s.sandboxId.startsWith(pausedSandboxId) && 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) + const extraSbxId = extraSbx.sandboxId.split('-')[0] + + 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.startsWith(extraSbxId), true) + + // 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 } }) + const extraSbxId = extraSbx.sandboxId.split('-')[0] + + // 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, extraSbxId) + + // 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 0000000000..ce5022eaf9 --- /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.betaPause(sandbox.sandboxId) + assert.isFalse( + await sandbox.isRunning(), + 'Sandbox should not be running after pause' + ) +}) + +sandboxTest.skipIf(isDebug)('resume sandbox', async ({ sandbox }) => { + await Sandbox.betaPause(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/sandbox/connect.test.ts b/packages/js-sdk/tests/sandbox/connect.test.ts index b1efb95207..98975b9216 100644 --- a/packages/js-sdk/tests/sandbox/connect.test.ts +++ b/packages/js-sdk/tests/sandbox/connect.test.ts @@ -30,7 +30,7 @@ sandboxTest.skipIf(isDebug)( const connectPromise = Sandbox.connect(sandbox.sandboxId) await expect(connectPromise).rejects.toThrowError( expect.objectContaining({ - message: `404: sandbox "${sandbox.sandboxId}" doesn't exist or you don't have access to it`, + name: 'NotFoundError', }) ) } diff --git a/packages/js-sdk/tests/sandbox/create.test.ts b/packages/js-sdk/tests/sandbox/create.test.ts index 485e06264d..69251c8a5a 100644 --- a/packages/js-sdk/tests/sandbox/create.test.ts +++ b/packages/js-sdk/tests/sandbox/create.test.ts @@ -23,7 +23,8 @@ test.skipIf(isDebug)('metadata', async () => { const sbx = await Sandbox.create(template, { timeoutMs: 5_000, metadata }) try { - const sbxs = await Sandbox.list() + const paginator = Sandbox.list() + const sbxs = await paginator.nextItems() const sbxInfo = sbxs.find((s) => s.sandboxId === sbx.sandboxId) assert.deepEqual(sbxInfo?.metadata, metadata) diff --git a/packages/js-sdk/tests/sandbox/kill.test.ts b/packages/js-sdk/tests/sandbox/kill.test.ts index b59d4e7ac5..be764b2662 100644 --- a/packages/js-sdk/tests/sandbox/kill.test.ts +++ b/packages/js-sdk/tests/sandbox/kill.test.ts @@ -3,9 +3,13 @@ import { expect } from 'vitest' import { Sandbox } from '../../src' import { sandboxTest, isDebug } from '../setup.js' -sandboxTest.skipIf(isDebug)('kill', async ({ sandbox }) => { +sandboxTest.skipIf(isDebug)('kill', async ({ sandbox, sandboxTestId }) => { await sandbox.kill() - const list = await Sandbox.list() - expect(list.map(s => s.sandboxId)).not.toContain(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) }) diff --git a/packages/js-sdk/tests/sandbox/metrics.test.ts b/packages/js-sdk/tests/sandbox/metrics.test.ts index b675432436..da66eb47b1 100644 --- a/packages/js-sdk/tests/sandbox/metrics.test.ts +++ b/packages/js-sdk/tests/sandbox/metrics.test.ts @@ -1,11 +1,11 @@ import { expect } from 'vitest' import { SandboxMetrics } from '../../src' -import {sandboxTest, isDebug, wait} from '../setup.js' +import { sandboxTest, isDebug, wait } from '../setup.js' sandboxTest.skipIf(isDebug)('sbx metrics', async ({ sandbox }) => { let metrics: SandboxMetrics[] - for (let i = 0; i < 10; i++) { + for (let i = 0; i < 15; i++) { metrics = await sandbox.getMetrics() if (metrics.length > 0) { break 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 0000000000..4cbfb1e54c --- /dev/null +++ b/packages/js-sdk/tests/sandbox/snapshot.test.ts @@ -0,0 +1,162 @@ +import { assert } from 'vitest' + +import { sandboxTest, isDebug } from '../setup.js' +import { Sandbox } from '../../src' + +sandboxTest.skipIf(isDebug)( + 'pause and resume a sandbox', + async ({ sandbox }) => { + assert.isTrue(await sandbox.isRunning()) + + await sandbox.betaPause() + + assert.isFalse(await sandbox.isRunning()) + + const resumedSandbox = await sandbox.connect() + assert.equal(resumedSandbox.sandboxId, sandbox.sandboxId) + + assert.isTrue(await sandbox.isRunning()) + } +) + +sandboxTest.skipIf(isDebug)( + 'pause and resume a sandbox with env vars', + async ({ template, sandboxTestId }) => { + // 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 sandbox = await Sandbox.create(template, { + envs: { TEST_VAR: 'sfisback' }, + metadata: { sandboxTestId }, + }) + + const cmd = await sandbox.commands.run('echo "$TEST_VAR"') + + assert.equal(cmd.exitCode, 0) + assert.equal(cmd.stdout.trim(), 'sfisback') + + await sandbox.betaPause() + + 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.betaPause() + 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.betaPause() + 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.betaPause() + 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.betaPause() + 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) + } +) diff --git a/packages/js-sdk/tests/setup.ts b/packages/js-sdk/tests/setup.ts index e0ffcd31e4..5224373dbc 100644 --- a/packages/js-sdk/tests/setup.ts +++ b/packages/js-sdk/tests/setup.ts @@ -4,13 +4,25 @@ import { template } from './template' interface SandboxFixture { sandbox: Sandbox + template: string + sandboxTestId: string } export const sandboxTest = base.extend({ - sandbox: [ + template, + sandboxTestId: [ // eslint-disable-next-line no-empty-pattern async ({}, use) => { - const sandbox = await Sandbox.create(template) + const id = `test-${generateRandomString()}` + await use(id) + }, + { auto: true }, + ], + sandbox: [ + async ({ sandboxTestId }, use) => { + const sandbox = await Sandbox.create(template, { + metadata: { sandboxTestId }, + }) try { await use(sandbox) } finally { @@ -32,6 +44,12 @@ export const sandboxTest = base.extend({ export const isDebug = process.env.E2B_DEBUG !== undefined export const isIntegrationTest = process.env.E2B_INTEGRATION_TEST !== undefined +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)) } diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md index 2ea53301e2..243f81a5d9 100644 --- a/packages/python-sdk/README.md +++ b/packages/python-sdk/README.md @@ -34,7 +34,7 @@ E2B_API_KEY=e2b_*** ```py from e2b_code_interpreter import Sandbox -with Sandbox() as sandbox: +with Sandbox.create() as sandbox: sandbox.run_code("x = 1") execution = sandbox.run_code("x+=1; x") print(execution.text) # outputs 2 diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index d8d85aec37..082b9d3717 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -14,7 +14,7 @@ from e2b import Sandbox # Create sandbox -sandbox = Sandbox() +sandbox = Sandbox.create() ``` ```py @@ -42,7 +42,7 @@ NotEnoughSpaceException, TemplateException, ) -from .sandbox.sandbox_api import SandboxInfo, SandboxMetrics +from .sandbox.sandbox_api import SandboxInfo, SandboxQuery, SandboxState, SandboxMetrics from .sandbox.commands.main import ProcessInfo from .sandbox.commands.command_handle import ( CommandResult, @@ -61,11 +61,13 @@ from .sandbox_sync.main import Sandbox from .sandbox_sync.filesystem.watch_handle import WatchHandle from .sandbox_sync.commands.command_handle import CommandHandle +from .sandbox_async.paginator import AsyncSandboxPaginator from .sandbox_async.utils import OutputHandler from .sandbox_async.main import AsyncSandbox from .sandbox_async.filesystem.watch_handle import AsyncWatchHandle from .sandbox_async.commands.command_handle import AsyncCommandHandle +from .sandbox_sync.paginator import SandboxPaginator __all__ = [ # API @@ -86,6 +88,9 @@ "SandboxInfo", "SandboxMetrics", "ProcessInfo", + "SandboxQuery", + "SandboxState", + "SandboxMetrics", # Command handle "CommandResult", "Stderr", @@ -101,10 +106,12 @@ "FileType", # Sync sandbox "Sandbox", + "SandboxPaginator", "WatchHandle", "CommandHandle", # Async sandbox "OutputHandler", + "AsyncSandboxPaginator", "AsyncSandbox", "AsyncWatchHandle", "AsyncCommandHandle", diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index 93953fa6a5..c211fcb441 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -92,6 +92,12 @@ def __init__( **(config.headers or {}), } + # Prevent passing these parameters twice + kwargs.pop("headers", None) + kwargs.pop("token", None) + kwargs.pop("auth_header_name", None) + kwargs.pop("prefix", None) + super().__init__( base_url=config.api_url, httpx_args={ 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 index e7a2d41450..d05b6f900b 100644 --- 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 @@ -18,7 +18,7 @@ def _get_kwargs( json_sandbox_ids = sandbox_ids - params["sandbox_ids"] = json_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} 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 index 7dd68edf36..704ace730b 100644 --- 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 @@ -29,7 +29,8 @@ def _get_kwargs( state_item = state_item_data.value json_state.append(state_item) - params["state"] = json_state + if not isinstance(json_state, Unset): + params["state"] = ",".join(str(item) for item in json_state) params["nextToken"] = next_token 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 index 557dcdc9a2..36d79cbf62 100644 --- a/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes.py +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes.py @@ -22,9 +22,8 @@ def _get_kwargs( "url": "/sandboxes", } - _body = body.to_dict() + _kwargs["json"] = body.to_dict() - _kwargs["json"] = _body headers["Content-Type"] = "application/json" _kwargs["headers"] = headers 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 index 6dd76cedca..5b667ba5dc 100644 --- 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 @@ -24,9 +24,8 @@ def _get_kwargs( "url": f"/sandboxes/{sandbox_id}/refreshes", } - _body = body.to_dict() + _kwargs["json"] = body.to_dict() - _kwargs["json"] = _body headers["Content-Type"] = "application/json" _kwargs["headers"] = headers 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 index 6f15b483cd..caca6292ab 100644 --- 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 @@ -23,9 +23,8 @@ def _get_kwargs( "url": f"/sandboxes/{sandbox_id}/resume", } - _body = body.to_dict() + _kwargs["json"] = body.to_dict() - _kwargs["json"] = _body headers["Content-Type"] = "application/json" _kwargs["headers"] = headers 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 index 18642da6b6..2756f30a86 100644 --- 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 @@ -24,9 +24,8 @@ def _get_kwargs( "url": f"/sandboxes/{sandbox_id}/timeout", } - _body = body.to_dict() + _kwargs["json"] = body.to_dict() - _kwargs["json"] = _body headers["Content-Type"] = "application/json" _kwargs["headers"] = headers diff --git a/packages/python-sdk/e2b/api/client/types.py b/packages/python-sdk/e2b/api/client/types.py index b9ed58b8aa..1b96ca408a 100644 --- a/packages/python-sdk/e2b/api/client/types.py +++ b/packages/python-sdk/e2b/api/client/types.py @@ -1,8 +1,8 @@ """Contains some shared types for properties""" -from collections.abc import MutableMapping +from collections.abc import Mapping, MutableMapping from http import HTTPStatus -from typing import BinaryIO, Generic, Literal, Optional, TypeVar +from typing import IO, BinaryIO, Generic, Literal, Optional, TypeVar, Union from attrs import define @@ -14,7 +14,15 @@ def __bool__(self) -> Literal[False]: UNSET: Unset = Unset() -FileJsonType = tuple[Optional[str], BinaryIO, Optional[str]] +# 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 @@ -25,7 +33,7 @@ class File: file_name: Optional[str] = None mime_type: Optional[str] = None - def to_tuple(self) -> FileJsonType: + 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 @@ -43,4 +51,4 @@ class Response(Generic[T]): parsed: Optional[T] -__all__ = ["UNSET", "File", "FileJsonType", "Response", "Unset"] +__all__ = ["UNSET", "File", "FileTypes", "RequestFiles", "Response", "Unset"] diff --git a/packages/python-sdk/e2b/sandbox/main.py b/packages/python-sdk/e2b/sandbox/main.py index 4bb86d46b8..d348729715 100644 --- a/packages/python-sdk/e2b/sandbox/main.py +++ b/packages/python-sdk/e2b/sandbox/main.py @@ -1,6 +1,6 @@ import urllib.parse -from typing import Optional +from typing import Optional, TypedDict from e2b.sandbox.signature import get_signature from e2b.connection_config import ConnectionConfig @@ -8,6 +8,14 @@ from httpx import Limits +class SandboxOpts(TypedDict): + sandbox_id: str + sandbox_domain: Optional[str] + envd_version: Optional[str] + envd_access_token: Optional[str] + connection_config: ConnectionConfig + + class SandboxBase: _limits = Limits( max_keepalive_connections=40, @@ -65,7 +73,7 @@ def sandbox_id(self) -> str: def _file_url( self, - path: Optional[str] = None, + path: str, user: str = "user", signature: Optional[str] = None, signature_expiration: Optional[int] = None, @@ -119,7 +127,7 @@ def download_url( def upload_url( self, - path: Optional[str] = None, + path: str, user: str = "user", use_signature_expiration: Optional[int] = None, ) -> str: diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 444ec6527c..0ae85f2683 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -1,8 +1,11 @@ from dataclasses import dataclass from typing import Optional, Dict, Union +from typing_extensions import Unpack from datetime import datetime +from e2b import ConnectionConfig from e2b.api.client.models import SandboxState, SandboxDetail, ListedSandbox +from e2b.connection_config import ApiParams @dataclass @@ -84,6 +87,9 @@ class SandboxQuery: metadata: Optional[dict[str, str]] = None """Filter sandboxes by metadata.""" + state: Optional[list[SandboxState]] = None + """Filter sandboxes by state.""" + @dataclass class SandboxMetrics: @@ -103,3 +109,34 @@ class SandboxMetrics: """Memory used in bytes.""" timestamp: datetime """Timestamp of the metric entry.""" + + +class SandboxPaginatorBase: + def __init__( + self, + query: Optional[SandboxQuery] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ): + self._config = ConnectionConfig(**opts) + + self.query = query + 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 diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 08d05b26d6..8bae8e2cb7 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -2,15 +2,16 @@ import logging import httpx -from typing import Dict, Optional, TypedDict, overload, List +from typing import Dict, Optional, overload, List from packaging.version import Version -from typing_extensions import Unpack +from typing_extensions import Unpack, Self from e2b.api.client.types import Unset from e2b.connection_config import ConnectionConfig, ApiParams from e2b.envd.api import ENVD_API_HEALTH_ROUTE, ahandle_envd_api_exception from e2b.exceptions import format_request_timeout_error, SandboxException +from e2b.sandbox.main import SandboxOpts from e2b.sandbox.sandbox_api import SandboxMetrics from e2b.sandbox.utils import class_method_variant from e2b.sandbox_async.filesystem.filesystem import Filesystem @@ -37,14 +38,6 @@ def pool(self): return self._pool -class AsyncSandboxOpts(TypedDict): - sandbox_id: str - sandbox_domain: Optional[str] - envd_version: Optional[str] - envd_access_token: Optional[str] - connection_config: ConnectionConfig - - class AsyncSandbox(SandboxApi): """ E2B cloud sandbox is a secure and isolated cloud environment. @@ -89,7 +82,7 @@ def pty(self) -> Pty: """ return self._pty - def __init__(self, **opts: Unpack[AsyncSandboxOpts]): + def __init__(self, **opts: Unpack[SandboxOpts]): """ Use `AsyncSandbox.create()` to create a new sandbox instead. """ @@ -165,9 +158,9 @@ async def create( metadata: Optional[Dict[str, str]] = None, envs: Optional[Dict[str, str]] = None, secure: Optional[bool] = None, - allow_internet_access: Optional[bool] = True, + allow_internet_access: bool = True, **opts: Unpack[ApiParams], - ) -> "AsyncSandbox": + ) -> Self: """ Create a new sandbox. @@ -184,90 +177,102 @@ async def create( Use this method instead of using the constructor to create a new sandbox. """ + return await cls._create( + template=template, + timeout=timeout, + metadata=metadata, + envs=envs, + secure=secure, + allow_internet_access=allow_internet_access, + **opts, + ) - extra_sandbox_headers = {} + @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. - debug = opts.get("debug") - if debug: - sandbox_id = "debug_sandbox_id" - sandbox_domain = None - envd_version = None - envd_access_token = None - else: - response = await cls._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, - **opts, - ) + With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc). - sandbox_id = response.sandbox_id - sandbox_domain = response.sandbox_domain - envd_version = response.envd_version - envd_access_token = response.envd_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 + :param timeout: Timeout for the sandbox in **seconds** + :return: A running sandbox instance - connection_config = ConnectionConfig( - extra_sandbox_headers=extra_sandbox_headers, - **opts, - ) + @example + ```python + sandbox = await AsyncSandbox.create() + await sandbox.beta_pause() - return cls( - sandbox_id=sandbox_id, - sandbox_domain=sandbox_domain, - envd_version=envd_version, - envd_access_token=envd_access_token, - connection_config=connection_config, - ) + # Another code block + same_sandbox = await sandbox.connect() + ``` + """ + ... + @overload @classmethod async def connect( cls, sandbox_id: str, + timeout: Optional[int] = None, **opts: Unpack[ApiParams], - ) -> "AsyncSandbox": + ) -> Self: """ - Connect to an existing sandbox. - With a sandbox ID, you can connect to the same sandbox from different places or environments (serverless functions, etc.). + Connect to a sandbox. If the sandbox is paused, it will be automatically resumed. + Sandbox must be either running or be paused. - :param sandbox_id: Sandbox ID + With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc). - :return: A Sandbox instance for the existing sandbox + :param sandbox_id: Sandbox ID + :param timeout: Timeout for the sandbox in **seconds** + :return: A running sandbox instance @example ```python sandbox = await AsyncSandbox.create() - sandbox_id = sandbox.sandbox_id + await AsyncSandbox.beta_pause(sandbox.sandbox_id) # Another code block - same_sandbox = await AsyncSandbox.connect(sandbox_id) + same_sandbox = await AsyncSandbox.connect(sandbox.sandbox_id)) + ``` """ - response = await cls._cls_get_info(sandbox_id, **opts) + ... - sandbox_headers = {} - envd_access_token = response._envd_access_token - if envd_access_token is not None and not isinstance(envd_access_token, Unset): - sandbox_headers["X-Access-Token"] = envd_access_token + @class_method_variant("_cls_connect") + 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. - connection_config = ConnectionConfig( - extra_sandbox_headers=sandbox_headers, + 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** + :return: A running sandbox instance + + @example + ```python + sandbox = await AsyncSandbox.create() + await sandbox.beta_pause() + + # Another code block + same_sandbox = await sandbox.connect() + ``` + """ + await SandboxApi._cls_resume( + sandbox_id=self.sandbox_id, + timeout=timeout, **opts, ) - return cls( - sandbox_id=sandbox_id, - sandbox_domain=response.sandbox_domain, - connection_config=connection_config, - envd_version=response.envd_version, - envd_access_token=envd_access_token, - ) + return self async def __aenter__(self): return self @@ -312,7 +317,7 @@ async def kill( :return: `True` if the sandbox was killed, `False` if the sandbox was not found """ - return await self._cls_kill( + return await SandboxApi._cls_kill( sandbox_id=self.sandbox_id, **self.connection_config.get_api_params(**opts), ) @@ -368,7 +373,7 @@ async def set_timeout( :param timeout: Timeout for the sandbox in **seconds** """ - await self._cls_set_timeout( + await SandboxApi._cls_set_timeout( sandbox_id=self.sandbox_id, timeout=timeout, **self.connection_config.get_api_params(**opts), @@ -411,7 +416,7 @@ async def get_info( :return: Sandbox info """ - return await self._cls_get_info( + return await SandboxApi._cls_get_info( sandbox_id=self.sandbox_id, **self.connection_config.get_api_params(**opts), ) @@ -478,9 +483,189 @@ async def get_metrics( "Disk metrics are not supported in this version of the sandbox, please rebuild the template to get disk metrics." ) - return await self._cls_get_metrics( + return await SandboxApi._cls_get_metrics( sandbox_id=self.sandbox_id, start=start, end=end, **self.connection_config.get_api_params(**opts), ) + + @classmethod + async def beta_create( + cls, + template: Optional[str] = None, + timeout: Optional[int] = None, + auto_pause: bool = False, + metadata: Optional[Dict[str, str]] = None, + envs: Optional[Dict[str, str]] = None, + secure: Optional[bool] = None, + allow_internet_access: bool = True, + **opts: Unpack[ApiParams], + ) -> Self: + """ + [BETA] This feature is in beta and may change in the future. + + 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 auto_pause: Automatically pause the sandbox after the timeout expires. Defaults to `False`. + :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 + :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. + + :return: A Sandbox instance for the new sandbox + + Use this method instead of using the constructor to create a new sandbox. + """ + + return await cls._create( + template=template, + timeout=timeout, + auto_pause=auto_pause, + metadata=metadata, + envs=envs, + secure=secure, + allow_internet_access=allow_internet_access, + **opts, + ) + + @overload + async def beta_pause( + self, + **opts: Unpack[ApiParams], + ) -> None: + """ + [BETA] This feature is in beta and may change in the future. + + Pause the sandbox. + + :return: Sandbox ID that can be used to resume the sandbox + """ + ... + + @overload + @staticmethod + async def beta_pause( + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> None: + """ + [BETA] This feature is in beta and may change in the future. + + Pause the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + + :return: Sandbox ID that can be used to resume the sandbox + """ + ... + + @class_method_variant("_cls_pause") + async def beta_pause( + self, + **opts: Unpack[ApiParams], + ) -> None: + """ + [BETA] This feature is in beta and may change in the future. + + Pause the sandbox. + + :return: Sandbox ID that can be used to resume the sandbox + """ + + await SandboxApi._cls_pause( + sandbox_id=self.sandbox_id, + **opts, + ) + + @classmethod + async def _cls_connect( + cls, + sandbox_id: str, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> Self: + await SandboxApi._cls_resume( + sandbox_id=sandbox_id, + timeout=timeout, + **opts, + ) + + response = await SandboxApi._cls_get_info(sandbox_id, **opts) + + sandbox_headers = {} + envd_access_token = response._envd_access_token + 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, + **opts, + ) + + return cls( + sandbox_id=response.sandbox_id, + sandbox_domain=response.sandbox_domain, + envd_version=response.envd_version, + envd_access_token=envd_access_token, + connection_config=connection_config, + ) + + @classmethod + async def _create( + cls, + template: Optional[str] = None, + timeout: Optional[int] = None, + auto_pause: bool = False, + allow_internet_access: bool = True, + metadata: Optional[Dict[str, str]] = None, + envs: Optional[Dict[str, str]] = None, + secure: Optional[bool] = None, + **opts: Unpack[ApiParams], + ) -> Self: + extra_sandbox_headers = {} + + debug = opts.get("debug") + if debug: + sandbox_id = "debug_sandbox_id" + sandbox_domain = None + envd_version = None + envd_access_token = None + else: + response = await SandboxApi._create_sandbox( + template=template or cls.default_template, + timeout=timeout or cls.default_sandbox_timeout, + auto_pause=auto_pause, + metadata=metadata, + env_vars=envs, + secure=secure, + allow_internet_access=allow_internet_access, + **opts, + ) + + sandbox_id = response.sandbox_id + sandbox_domain = response.sandbox_domain + envd_version = response.envd_version + envd_access_token = response.envd_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 + + connection_config = ConnectionConfig( + extra_sandbox_headers=extra_sandbox_headers, + **opts, + ) + + return cls( + sandbox_id=sandbox_id, + sandbox_domain=sandbox_domain, + envd_version=envd_version, + envd_access_token=envd_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 0000000000..cbcf2d18a7 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_async/paginator.py @@ -0,0 +1,72 @@ +import urllib.parse +from typing import Optional, List + +from e2b.api.client.api.sandboxes import get_v2_sandboxes +from e2b.api.client.types import UNSET +from e2b.exceptions import SandboxException +from e2b.sandbox.main import SandboxBase +from e2b.sandbox.sandbox_api import SandboxPaginatorBase, SandboxInfo +from e2b.api import AsyncApiClient, handle_api_exception +from e2b.api.client.models.error import Error + + +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) -> List[SandboxInfo]: + """ + Returns the next page of sandboxes. + + Call this method only if `has_next` is `True`, otherwise it will raise an exception. + + :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) + + async with AsyncApiClient( + self._config, + limits=SandboxBase._limits, + ) as api_client: + 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._next_token = res.headers.get("x-next-token") + self._has_next = bool(self._next_token) + + 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") + + return [SandboxInfo._from_listed_sandbox(sandbox) for sandbox 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 index 3f0082b96a..f11f2642f6 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -1,80 +1,57 @@ import datetime -import urllib.parse from typing import Optional, Dict, List from packaging.version import Version from typing_extensions import Unpack +from e2b.api.client.types import UNSET from e2b.sandbox.main import SandboxBase -from e2b.sandbox.sandbox_api import ( - SandboxInfo, - SandboxQuery, - SandboxMetrics, -) -from e2b.exceptions import TemplateException, SandboxException +from e2b.sandbox.sandbox_api import SandboxInfo, SandboxMetrics, SandboxQuery +from e2b.exceptions import TemplateException, SandboxException, NotFoundException from e2b.api import AsyncApiClient, SandboxCreateResponse from e2b.api.client.models import ( NewSandbox, PostSandboxesSandboxIDTimeoutBody, Error, + ResumedSandbox, ) from e2b.api.client.api.sandboxes import ( get_sandboxes_sandbox_id, post_sandboxes_sandbox_id_timeout, - get_sandboxes, delete_sandboxes_sandbox_id, post_sandboxes, get_sandboxes_sandbox_id_metrics, + post_sandboxes_sandbox_id_pause, + post_sandboxes_sandbox_id_resume, ) from e2b.connection_config import ConnectionConfig, ApiParams from e2b.api import handle_api_exception +from e2b.sandbox_async.paginator import AsyncSandboxPaginator class SandboxApi(SandboxBase): - @classmethod - async def list( - cls, + @staticmethod + def list( query: Optional[SandboxQuery] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, **opts: Unpack[ApiParams], - ) -> List[SandboxInfo]: + ) -> AsyncSandboxPaginator: """ List all running sandboxes. - :param query: Filter the list of sandboxes, e.g. by metadata `SandboxQuery(metadata={"key": "value"})`, if there are multiple filters, they are combined with AND. + :param query: Filter the list of sandboxes by metadata or state, e.g. `SandboxListQuery(metadata={"key": "value"})` or `SandboxListQuery(state=[SandboxState.RUNNING])` + :param limit: Maximum number of sandboxes to return per page + :param next_token: Token for pagination :return: List of running sandboxes """ - config = ConnectionConfig(**opts) - - # Convert filters to the format expected by the API - metadata = None - if query: - if query.metadata: - quoted_metadata = { - urllib.parse.quote(k): urllib.parse.quote(v) - for k, v in query.metadata.items() - } - metadata = urllib.parse.urlencode(quoted_metadata) - - async with AsyncApiClient( - config, - limits=cls._limits, - ) as api_client: - res = await get_sandboxes.asyncio_detailed( - client=api_client, - metadata=metadata, - ) - - 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") - - return [SandboxInfo._from_listed_sandbox(sandbox) for sandbox in res.parsed] + return AsyncSandboxPaginator( + query=query, + limit=limit, + next_token=next_token, + **opts, + ) @classmethod async def _cls_get_info( @@ -92,7 +69,7 @@ async def _cls_get_info( async with AsyncApiClient( config, - limits=cls._limits, + limits=SandboxBase._limits, ) as api_client: res = await get_sandboxes_sandbox_id.asyncio_detailed( sandbox_id, @@ -124,7 +101,7 @@ async def _cls_kill( async with AsyncApiClient( config, - limits=cls._limits, + limits=SandboxBase._limits, ) as api_client: res = await delete_sandboxes_sandbox_id.asyncio_detailed( sandbox_id, @@ -154,7 +131,7 @@ async def _cls_set_timeout( async with AsyncApiClient( config, - limits=cls._limits, + limits=SandboxBase._limits, ) as api_client: res = await post_sandboxes_sandbox_id_timeout.asyncio_detailed( sandbox_id, @@ -170,21 +147,23 @@ async def _create_sandbox( cls, template: str, timeout: int, + auto_pause: bool, + allow_internet_access: bool, metadata: Optional[Dict[str, str]] = None, env_vars: Optional[Dict[str, str]] = None, secure: Optional[bool] = None, - allow_internet_access: Optional[bool] = True, **opts: Unpack[ApiParams], ) -> SandboxCreateResponse: config = ConnectionConfig(**opts) async with AsyncApiClient( config, - limits=cls._limits, + limits=SandboxBase._limits, ) as api_client: res = await post_sandboxes.asyncio_detailed( body=NewSandbox( template_id=template, + auto_pause=auto_pause, metadata=metadata or {}, timeout=timeout, env_vars=env_vars or {}, @@ -200,6 +179,9 @@ async def _create_sandbox( 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( @@ -239,12 +221,12 @@ async def _cls_get_metrics( async with AsyncApiClient( config, - limits=cls._limits, + limits=SandboxBase._limits, ) as api_client: res = await get_sandboxes_sandbox_id_metrics.asyncio_detailed( sandbox_id, - start=int(start.timestamp() * 1000) if start else None, - end=int(end.timestamp() * 1000) if end else None, + start=int(start.timestamp() * 1000) if start else UNSET, + end=int(end.timestamp() * 1000) if end else UNSET, client=api_client, ) @@ -271,3 +253,79 @@ async def _cls_get_metrics( ) for metric in res.parsed ] + + @classmethod + async def _cls_pause( + cls, + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> str: + config = ConnectionConfig(**opts) + + async with AsyncApiClient( + config, + limits=SandboxBase._limits, + ) as api_client: + res = await post_sandboxes_sandbox_id_pause.asyncio_detailed( + sandbox_id, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code == 409: + return sandbox_id + + if res.status_code >= 300: + raise handle_api_exception(res) + + return sandbox_id + + @classmethod + async def _cls_resume( + cls, + sandbox_id: str, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> bool: + timeout = timeout or SandboxBase.default_sandbox_timeout + + # Temporary solution (02/12/2025), + # Options discussed: + # 1. No set - never sure how long the sandbox will be running + # 2. Always set the timeout in code - the user can't just connect to the sandbox + # without changing the timeout, round trip to the server time + # 3. Set the timeout in resume on backend - side effect on error + # 4. Create new endpoint for connect + try: + await SandboxApi._cls_set_timeout( + sandbox_id=sandbox_id, + timeout=timeout, + **opts, + ) + return False + except SandboxException: + # Sandbox is not running, resume it + config = ConnectionConfig(**opts) + + async with AsyncApiClient( + config, + limits=SandboxBase._limits, + ) as api_client: + res = await post_sandboxes_sandbox_id_resume.asyncio_detailed( + sandbox_id, + client=api_client, + body=ResumedSandbox(timeout=timeout), + ) + + if res.status_code == 404: + raise NotFoundException(f"Paused sandbox {sandbox_id} not found") + + if res.status_code == 409: + return False + + if res.status_code >= 300: + raise handle_api_exception(res) + + return True diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 1ee7c6f7ba..4b3bb2b3e8 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -1,16 +1,18 @@ import datetime import logging + import httpx from typing import Dict, Optional, overload, List from packaging.version import Version -from typing_extensions import Unpack +from typing_extensions import Unpack, Self from e2b.api.client.types import Unset from e2b.connection_config import ConnectionConfig, ApiParams from e2b.envd.api import ENVD_API_HEALTH_ROUTE, handle_envd_api_exception from e2b.exceptions import SandboxException, format_request_timeout_error +from e2b.sandbox.main import SandboxOpts from e2b.sandbox.sandbox_api import SandboxMetrics from e2b.sandbox.utils import class_method_variant from e2b.sandbox_sync.filesystem.filesystem import Filesystem @@ -50,13 +52,13 @@ class Sandbox(SandboxApi): Check docs [here](https://e2b.dev/docs). - Use the `Sandbox()` to create a new sandbox. + Use the `Sandbox.create()` to create a new sandbox. Example: ```python from e2b import Sandbox - sandbox = Sandbox() + sandbox = Sandbox.create() ``` """ @@ -81,105 +83,22 @@ def pty(self) -> Pty: """ return self._pty - def __init__( - self, - template: Optional[str] = None, - timeout: Optional[int] = None, - metadata: Optional[Dict[str, str]] = None, - envs: Optional[Dict[str, str]] = None, - secure: Optional[bool] = None, - allow_internet_access: Optional[bool] = True, - _sandbox_id: Optional[str] = None, - **opts: Unpack[ApiParams], - ): + def __init__(self, **opts: Unpack[SandboxOpts]): """ - 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 - :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. + :deprecated: This constructor is deprecated - :return: Sandbox instance for the new sandbox + Use `Sandbox.create()` to create a new sandbox instead. """ - if _sandbox_id and (metadata is not None or template is not None): - raise SandboxException( - "Cannot set metadata or timeout when connecting to an existing sandbox. " - "Use Sandbox.connect method instead.", - ) - - extra_sandbox_headers = {} - - sandbox_domain: Optional[str] = None - envd_version: Optional[str] = None - envd_access_token: Optional[str] = None - - debug = opts.get("debug") - - if debug: - _sandbox_id = "debug_sandbox_id" - elif _sandbox_id is not None: - response = self._cls_get_info( - _sandbox_id, - **opts, - ) - - envd_version = response.envd_version - sandbox_domain = response.sandbox_domain - envd_access_token = response._envd_access_token + super().__init__(**opts) - if envd_access_token is not None and not isinstance( - envd_access_token, Unset - ): - extra_sandbox_headers["X-Access-Token"] = envd_access_token - else: - template = template or self.default_template - timeout = timeout or self.default_sandbox_timeout - response = self._create_sandbox( - template=template, - timeout=timeout, - metadata=metadata, - env_vars=envs, - secure=secure or False, - allow_internet_access=allow_internet_access, - **opts, - ) - _sandbox_id = response.sandbox_id - envd_version = response.envd_version - sandbox_domain = response.sandbox_domain - envd_access_token = response.envd_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 - - connection_config = ConnectionConfig( - extra_sandbox_headers=extra_sandbox_headers, - **opts, - ) - - super().__init__( - sandbox_id=_sandbox_id, - envd_version=envd_version, - sandbox_domain=sandbox_domain, - envd_access_token=envd_access_token, - connection_config=connection_config, + self._transport = TransportWithLogger( + limits=self._limits, proxy=self.connection_config.proxy ) - - proxy = opts.get("proxy") - self._transport = TransportWithLogger(limits=self._limits, proxy=proxy) - self._envd_api = httpx.Client( base_url=self.envd_api_url, transport=self._transport, headers=self.connection_config.sandbox_headers, ) - self._filesystem = Filesystem( self.envd_api_url, self._envd_version, @@ -187,13 +106,11 @@ def __init__( self._transport.pool, self._envd_api, ) - self._commands = Commands( self.envd_api_url, self.connection_config, self._transport.pool, ) - self._pty = Pty( self.envd_api_url, self.connection_config, @@ -210,7 +127,7 @@ def is_running(self, request_timeout: Optional[float] = None) -> bool: Example ```python - sandbox = Sandbox() + sandbox = Sandbox.create() sandbox.is_running() # Returns True sandbox.kill() @@ -236,33 +153,131 @@ def is_running(self, request_timeout: Optional[float] = None) -> bool: 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: Optional[bool] = None, + allow_internet_access: bool = True, + **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 + :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. + + :return: A Sandbox instance for the new sandbox + + Use this method instead of using the constructor to create a new sandbox. + """ + return cls._create( + template=template, + timeout=timeout, + metadata=metadata, + envs=envs, + secure=secure, + allow_internet_access=allow_internet_access, + **opts, + ) + + @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** + :return: A running sandbox instance + + @example + ```python + sandbox = Sandbox.create() + sandbox.beta_pause() + + # Another code block + same_sandbox = sandbox.connect() + + :return: A running sandbox instance + """ + ... + + @overload @classmethod def connect( cls, sandbox_id: str, + timeout: Optional[int] = None, **opts: Unpack[ApiParams], - ): + ) -> Self: """ - Connect to an existing sandbox. - With a sandbox ID, you can connect to the same sandbox from different places or environments (serverless functions, etc.). + 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** + :return: A running sandbox instance @example ```python - sandbox = Sandbox() - sandbox_id = sandbox.sandbox_id + sandbox = Sandbox.create() + Sandbox.beta_pause(sandbox.sandbox_id) # Another code block - same_sandbox = Sandbox.connect(sandbox_id) + same_sandbox = Sandbox.connect(sandbox.sandbox_id) ``` """ + ... - return cls( - _sandbox_id=sandbox_id, + @class_method_variant("_cls_connect") + 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** + :return: A running sandbox instance + + @example + ```python + sandbox = Sandbox.create() + sandbox.beta_pause() + + # Another code block + same_sandbox = sandbox.connect() + ``` + """ + SandboxApi._cls_resume( + sandbox_id=self.sandbox_id, + timeout=timeout, **opts, ) + return self + def __enter__(self): return self @@ -306,7 +321,7 @@ def kill( :return: `True` if the sandbox was killed, `False` if the sandbox was not found """ - return self._cls_kill( + return SandboxApi._cls_kill( sandbox_id=self.sandbox_id, **self.connection_config.get_api_params(**opts), ) @@ -364,7 +379,7 @@ def set_timeout( """ - self._cls_set_timeout( + SandboxApi._cls_set_timeout( sandbox_id=self.sandbox_id, timeout=timeout, **self.connection_config.get_api_params(**opts), @@ -407,7 +422,7 @@ def get_info( :return: Sandbox info """ - return self._cls_get_info( + return SandboxApi._cls_get_info( sandbox_id=self.sandbox_id, **self.connection_config.get_api_params(**opts), ) @@ -474,9 +489,181 @@ def get_metrics( "Disk metrics are not supported in this version of the sandbox, please rebuild the template to get disk metrics." ) - return self._cls_get_metrics( + return SandboxApi._cls_get_metrics( sandbox_id=self.sandbox_id, start=start, end=end, **self.connection_config.get_api_params(**opts), ) + + @classmethod + def beta_create( + cls, + template: Optional[str] = None, + timeout: Optional[int] = None, + auto_pause: bool = False, + metadata: Optional[Dict[str, str]] = None, + envs: Optional[Dict[str, str]] = None, + secure: Optional[bool] = None, + allow_internet_access: bool = True, + **opts: Unpack[ApiParams], + ): + """ + [BETA] This feature is in beta and may change in the future. + + 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 auto_pause: Automatically pause the sandbox after the timeout expires. Defaults to `False`. + :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 + :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. + + :return: A Sandbox instance for the new sandbox + + Use this method instead of using the constructor to create a new sandbox. + """ + return cls._create( + template=template, + auto_pause=auto_pause, + timeout=timeout, + metadata=metadata, + envs=envs, + secure=secure, + allow_internet_access=allow_internet_access, + **opts, + ) + + @overload + def beta_pause( + self, + **opts: Unpack[ApiParams], + ) -> None: + """ + [BETA] This feature is in beta and may change in the future. + + Pause the sandbox. + """ + ... + + @overload + @classmethod + def beta_pause( + cls, + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> None: + """ + [BETA] This feature is in beta and may change in the future. + + Pause the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + """ + ... + + @class_method_variant("_cls_pause") + def beta_pause( + self, + **opts: Unpack[ApiParams], + ) -> None: + """ + [BETA] This feature is in beta and may change in the future. + + Pause the sandbox. + + :return: Sandbox ID that can be used to resume the sandbox + """ + + SandboxApi._cls_pause( + sandbox_id=self.sandbox_id, + **opts, + ) + + @classmethod + def _cls_connect( + cls, + sandbox_id: str, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> Self: + SandboxApi._cls_resume(sandbox_id, timeout, **opts) + + response = cls._cls_get_info(sandbox_id, **opts) + + sandbox_headers = {} + envd_access_token = response._envd_access_token + 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, + **opts, + ) + + return cls( + sandbox_id=sandbox_id, + sandbox_domain=response.sandbox_domain, + connection_config=connection_config, + envd_version=response.envd_version, + envd_access_token=envd_access_token, + ) + + @classmethod + def _create( + cls, + template: Optional[str] = None, + timeout: Optional[int] = None, + auto_pause: bool = False, + metadata: Optional[Dict[str, str]] = None, + envs: Optional[Dict[str, str]] = None, + secure: Optional[bool] = None, + allow_internet_access: bool = True, + **opts: Unpack[ApiParams], + ) -> Self: + extra_sandbox_headers = {} + + debug = opts.get("debug") + if debug: + sandbox_id = "debug_sandbox_id" + sandbox_domain = None + envd_version = None + envd_access_token = None + else: + response = SandboxApi._create_sandbox( + template=template or cls.default_template, + timeout=timeout or cls.default_sandbox_timeout, + auto_pause=auto_pause, + metadata=metadata, + env_vars=envs, + secure=secure, + allow_internet_access=allow_internet_access, + **opts, + ) + + sandbox_id = response.sandbox_id + sandbox_domain = response.sandbox_domain + envd_version = response.envd_version + envd_access_token = response.envd_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 + + connection_config = ConnectionConfig( + extra_sandbox_headers=extra_sandbox_headers, + **opts, + ) + + return cls( + sandbox_id=sandbox_id, + sandbox_domain=sandbox_domain, + envd_version=envd_version, + envd_access_token=envd_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 0000000000..87502c1616 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_sync/paginator.py @@ -0,0 +1,72 @@ +import urllib.parse +from typing import Optional, List + +from e2b.api.client.api.sandboxes import get_v2_sandboxes +from e2b.api.client.types import UNSET +from e2b.exceptions import SandboxException +from e2b.sandbox.main import SandboxBase +from e2b.sandbox.sandbox_api import SandboxPaginatorBase, SandboxInfo +from e2b.api import handle_api_exception, ApiClient +from e2b.api.client.models.error import Error + + +class SandboxPaginator(SandboxPaginatorBase): + """ + Paginator for listing sandboxes. + + Example: + ```python + paginator = AsyncSandbox.list() + + while paginator.has_next: + sandboxes = await paginator.next_items() + print(sandboxes) + ``` + """ + + def next_items(self) -> List[SandboxInfo]: + """ + Returns the next page of sandboxes. + + Call this method only if `has_next` is `True`, otherwise it will raise an exception. + + :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) + + with ApiClient( + self._config, + limits=SandboxBase._limits, + ) as api_client: + 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._next_token = res.headers.get("x-next-token") + self._has_next = bool(self._next_token) + + 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") + + return [SandboxInfo._from_listed_sandbox(sandbox) for sandbox 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 index cde285fa39..f2a264cc66 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -1,77 +1,56 @@ import datetime -import urllib.parse from typing import Optional, Dict, List from packaging.version import Version from typing_extensions import Unpack -from e2b.sandbox.sandbox_api import ( - SandboxInfo, - SandboxQuery, - SandboxMetrics, -) +from e2b.sandbox.sandbox_api import SandboxInfo, SandboxMetrics, SandboxQuery from e2b.sandbox.main import SandboxBase -from e2b.exceptions import TemplateException, SandboxException +from e2b.exceptions import TemplateException, SandboxException, NotFoundException from e2b.api import ApiClient, SandboxCreateResponse from e2b.api.client.models import ( NewSandbox, PostSandboxesSandboxIDTimeoutBody, Error, + ResumedSandbox, ) from e2b.api.client.api.sandboxes import ( get_sandboxes_sandbox_id, post_sandboxes_sandbox_id_timeout, - get_sandboxes, delete_sandboxes_sandbox_id, post_sandboxes, get_sandboxes_sandbox_id_metrics, + post_sandboxes_sandbox_id_resume, + post_sandboxes_sandbox_id_pause, ) from e2b.connection_config import ConnectionConfig, ApiParams from e2b.api import handle_api_exception +from e2b.sandbox_sync.paginator import SandboxPaginator class SandboxApi(SandboxBase): - @classmethod + @staticmethod def list( - cls, query: Optional[SandboxQuery] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, **opts: Unpack[ApiParams], - ) -> List[SandboxInfo]: + ) -> SandboxPaginator: """ List all running sandboxes. - :param query: Filter the list of sandboxes, e.g. by metadata `SandboxQuery(metadata={"key": "value"})`, if there are multiple filters, they are combined with AND. + :param query: Filter the list of sandboxes by metadata or state, e.g. `SandboxListQuery(metadata={"key": "value"})` or `SandboxListQuery(state=[SandboxState.RUNNING])` + :param limit: Maximum number of sandboxes to return per page + :param next_token: Token for pagination :return: List of running sandboxes """ - config = ConnectionConfig(**opts) - - # Convert filters to the format expected by the API - metadata = None - if query: - if query.metadata: - quoted_metadata = { - urllib.parse.quote(k): urllib.parse.quote(v) - for k, v in query.metadata.items() - } - metadata = urllib.parse.urlencode(quoted_metadata) - - with ApiClient( - config, - limits=cls._limits, - ) as api_client: - res = get_sandboxes.sync_detailed(client=api_client, metadata=metadata) - - 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") - - return [SandboxInfo._from_listed_sandbox(sandbox) for sandbox in res.parsed] + return SandboxPaginator( + query=query, + limit=limit, + next_token=next_token, + **opts, + ) @classmethod def _cls_get_info( @@ -89,7 +68,7 @@ def _cls_get_info( with ApiClient( config, - limits=cls._limits, + limits=SandboxBase._limits, ) as api_client: res = get_sandboxes_sandbox_id.sync_detailed( sandbox_id, @@ -121,7 +100,7 @@ def _cls_kill( with ApiClient( config, - limits=cls._limits, + limits=SandboxBase._limits, ) as api_client: res = delete_sandboxes_sandbox_id.sync_detailed( sandbox_id, @@ -151,7 +130,7 @@ def _cls_set_timeout( with ApiClient( config, - limits=cls._limits, + limits=SandboxBase._limits, ) as api_client: res = post_sandboxes_sandbox_id_timeout.sync_detailed( sandbox_id, @@ -167,18 +146,20 @@ def _create_sandbox( cls, template: str, timeout: int, + auto_pause: bool, + allow_internet_access: bool, metadata: Optional[Dict[str, str]] = None, env_vars: Optional[Dict[str, str]] = None, secure: Optional[bool] = None, - allow_internet_access: Optional[bool] = True, **opts: Unpack[ApiParams], ) -> SandboxCreateResponse: config = ConnectionConfig(**opts) - with ApiClient(config, limits=cls._limits) as api_client: + with ApiClient(config, limits=SandboxBase._limits) as api_client: res = post_sandboxes.sync_detailed( body=NewSandbox( template_id=template, + auto_pause=auto_pause, metadata=metadata or {}, timeout=timeout, env_vars=env_vars or {}, @@ -194,6 +175,9 @@ def _create_sandbox( 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( @@ -224,7 +208,7 @@ def _cls_get_metrics( with ApiClient( config, - limits=cls._limits, + limits=SandboxBase._limits, ) as api_client: res = get_sandboxes_sandbox_id_metrics.sync_detailed( sandbox_id, @@ -255,3 +239,79 @@ def _cls_get_metrics( ) for metric in res.parsed ] + + @classmethod + def _cls_resume( + cls, + sandbox_id: str, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> bool: + timeout = timeout or SandboxBase.default_sandbox_timeout + + # Temporary solution (02/12/2025), + # Options discussed: + # 1. No set - never sure how long the sandbox will be running + # 2. Always set the timeout in code - the user can't just connect to the sandbox + # without changing the timeout, round trip to the server time + # 3. Set the timeout in resume on backend - side effect on error + # 4. Create new endpoint for connect + try: + cls._cls_set_timeout( + sandbox_id=sandbox_id, + timeout=timeout, + **opts, + ) + return False + except SandboxException: + # Sandbox is not running, resume it + config = ConnectionConfig(**opts) + + with ApiClient( + config, + limits=SandboxBase._limits, + ) as api_client: + res = post_sandboxes_sandbox_id_resume.sync_detailed( + sandbox_id, + client=api_client, + body=ResumedSandbox(timeout=timeout), + ) + + if res.status_code == 404: + raise NotFoundException(f"Paused sandbox {sandbox_id} not found") + + if res.status_code == 409: + return False + + if res.status_code >= 300: + raise handle_api_exception(res) + + return True + + @classmethod + def _cls_pause( + cls, + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> str: + config = ConnectionConfig(**opts) + + with ApiClient( + config, + limits=SandboxBase._limits, + ) as api_client: + res = post_sandboxes_sandbox_id_pause.sync_detailed( + sandbox_id, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code == 409: + return sandbox_id + + if res.status_code >= 300: + raise handle_api_exception(res) + + return sandbox_id diff --git a/packages/python-sdk/pytest.ini b/packages/python-sdk/pytest.ini index 49070c425a..1442bee9e1 100644 --- a/packages/python-sdk/pytest.ini +++ b/packages/python-sdk/pytest.ini @@ -4,4 +4,4 @@ markers = skip_debug: skip test if E2B_DEBUG is set. asyncio_mode=auto -addopts = "--import-mode=importlib" "--numprocesses=2" +addopts = "--import-mode=importlib" "--numprocesses=4" 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 index ff86307af3..ace0deff33 100644 --- a/packages/python-sdk/tests/async/api_async/test_sbx_kill.py +++ b/packages/python-sdk/tests/async/api_async/test_sbx_kill.py @@ -1,14 +1,19 @@ import pytest -from e2b import AsyncSandbox +from e2b import AsyncSandbox, SandboxQuery, SandboxState @pytest.mark.skip_debug() -async def test_kill_existing_sandbox(async_sandbox: AsyncSandbox): +async def test_kill_existing_sandbox(async_sandbox: AsyncSandbox, sandbox_test_id: str): assert await AsyncSandbox.kill(async_sandbox.sandbox_id) - list = await AsyncSandbox.list() - assert async_sandbox.sandbox_id not in [s.sandbox_id for s in list] + 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() 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 index 1979fc23c8..028f254ac6 100644 --- a/packages/python-sdk/tests/async/api_async/test_sbx_list.py +++ b/packages/python-sdk/tests/async/api_async/test_sbx_list.py @@ -1,38 +1,206 @@ -import random -import string +import time import pytest -from e2b import AsyncSandbox -from e2b.sandbox.sandbox_api import SandboxQuery +from e2b import AsyncSandbox, SandboxQuery, SandboxState @pytest.mark.skip_debug() -async def test_list_sandboxes(async_sandbox: AsyncSandbox): - sandboxes = await AsyncSandbox.list() - assert len(sandboxes) > 0 +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(template): - unique_id = "".join(random.choices(string.ascii_letters, k=5)) - sbx = await AsyncSandbox.create( - template=template, metadata={"unique_id": unique_id} - ) +async def test_list_sandboxes_with_filter(sandbox_test_id: str): + unique_id = str(int(time.time())) + extra_sbx = await AsyncSandbox.create(metadata={"unique_id": unique_id}) + try: - # There's an extra sandbox created by the test runner - sandboxes = await AsyncSandbox.list( + paginator = AsyncSandbox.list( query=SandboxQuery(metadata={"unique_id": unique_id}) ) + sandboxes = await paginator.next_items() assert len(sandboxes) == 1 - assert sandboxes[0].metadata["unique_id"] == unique_id + assert sandboxes[0].sandbox_id == extra_sbx.sandbox_id finally: - await sbx.kill() + await extra_sbx.kill() @pytest.mark.skip_debug() -async def test_list_sandboxes_with_empty_filter(async_sandbox: AsyncSandbox): - sandboxes = await AsyncSandbox.list(query=SandboxQuery()) - assert len(sandboxes) > 0 - assert async_sandbox.sandbox_id in [sbx.sandbox_id for sbx in sandboxes] +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 + paused_sandbox_id = async_sandbox.sandbox_id.split("-")[0] + assert any( + s.sandbox_id.startswith(paused_sandbox_id) and s.state == SandboxState.PAUSED + for s in sandboxes + ) + + +@pytest.mark.skip_debug() +async def test_paginate_running_sandboxes( + async_sandbox: AsyncSandbox, sandbox_test_id: str +): + # Create extra sandbox + extra_sbx = await AsyncSandbox.create(metadata={"sandbox_test_id": sandbox_test_id}) + + try: + # 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 == 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 + finally: + await extra_sbx.kill() + + +@pytest.mark.skip_debug() +async def test_paginate_paused_sandboxes( + async_sandbox: AsyncSandbox, sandbox_test_id: str +): + sandbox_id = async_sandbox.sandbox_id.split("-")[0] + await async_sandbox.beta_pause() + + # create another paused sandbox + extra_sbx = await AsyncSandbox.create(metadata={"sandbox_test_id": sandbox_test_id}) + extra_sbx_id = extra_sbx.sandbox_id.split("-")[0] + await extra_sbx.beta_pause() + + try: + # 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.startswith(extra_sbx_id) is True + + # 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.startswith(sandbox_id) is True + finally: + await extra_sbx.kill() + + +@pytest.mark.skip_debug() +async def test_paginate_running_and_paused_sandboxes( + async_sandbox: AsyncSandbox, sandbox_test_id: str +): + # Create extra paused sandbox + extra_sbx = await AsyncSandbox.create(metadata={"sandbox_test_id": sandbox_test_id}) + extra_sbx_id = extra_sbx.sandbox_id.split("-")[0] + await extra_sbx.beta_pause() + + try: + # 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.startswith(extra_sbx_id) is True + + # 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 + finally: + await extra_sbx.kill() + + +@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 0000000000..0fa2a34e82 --- /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.beta_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.beta_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/test_create.py b/packages/python-sdk/tests/async/sandbox_async/test_create.py index 2574d5d173..ac142cb0f1 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_create.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_create.py @@ -1,6 +1,6 @@ import pytest -from e2b import AsyncSandbox +from e2b import AsyncSandbox, SandboxQuery @pytest.mark.skip_debug() @@ -20,9 +20,12 @@ async def test_metadata(template): ) try: - sbxs = await AsyncSandbox.list() + paginator = AsyncSandbox.list( + query=SandboxQuery(metadata={"test-key": "test-value"}) + ) + sandboxes = await paginator.next_items() - for sbx_info in sbxs: + 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" diff --git a/packages/python-sdk/tests/async/sandbox_async/test_kill.py b/packages/python-sdk/tests/async/sandbox_async/test_kill.py index ea44b2fb73..f57502bd05 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_kill.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_kill.py @@ -1,11 +1,16 @@ import pytest -from e2b import AsyncSandbox +from e2b import AsyncSandbox, SandboxQuery, SandboxState @pytest.mark.skip_debug() -async def test_kill(async_sandbox: AsyncSandbox): +async def test_kill(async_sandbox: AsyncSandbox, sandbox_test_id: str): await async_sandbox.kill() - list = await AsyncSandbox.list() - assert async_sandbox.sandbox_id not in [s.sandbox_id for s in list] + 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 index 3d1e30a11d..d2ab0191f3 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_metrics.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_metrics.py @@ -9,7 +9,7 @@ async def test_sbx_metrics(async_sandbox: AsyncSandbox): # Wait for the sandbox to have some metrics metrics = [] - for _ in range(10): + for _ in range(15): metrics = await async_sandbox.get_metrics() if len(metrics) > 0: break 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 0000000000..3db48afd70 --- /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.beta_pause() + assert not await async_sandbox.is_running() + + resumed_sandbox = await async_sandbox.connect() + assert await async_sandbox.is_running() + assert resumed_sandbox.is_running() + assert resumed_sandbox.sandbox_id == async_sandbox.sandbox_id diff --git a/packages/python-sdk/tests/conftest.py b/packages/python-sdk/tests/conftest.py index ae0c234600..6f51a5c6b0 100644 --- a/packages/python-sdk/tests/conftest.py +++ b/packages/python-sdk/tests/conftest.py @@ -1,4 +1,5 @@ import asyncio +import uuid import pytest import pytest_asyncio @@ -15,14 +16,19 @@ ) +@pytest.fixture(scope="session") +def sandbox_test_id(): + return f"test_{uuid.uuid4()}" + + @pytest.fixture() def template(): return "base" @pytest.fixture() -def sandbox(template, debug): - sandbox = Sandbox(template) +def sandbox(template, debug, sandbox_test_id): + sandbox = Sandbox.create(template, metadata={"sandbox_test_id": sandbox_test_id}) try: yield sandbox @@ -37,8 +43,10 @@ def sandbox(template, debug): @pytest_asyncio.fixture -async def async_sandbox(template, debug): - sandbox = await AsyncSandbox.create(template) +async def async_sandbox(template, debug, sandbox_test_id): + sandbox = await AsyncSandbox.create( + template, metadata={"sandbox_test_id": sandbox_test_id} + ) try: yield sandbox 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 index ab9d18b21c..dfc587646f 100644 --- a/packages/python-sdk/tests/sync/api_sync/test_sbx_kill.py +++ b/packages/python-sdk/tests/sync/api_sync/test_sbx_kill.py @@ -1,14 +1,19 @@ import pytest -from e2b import Sandbox +from e2b import Sandbox, SandboxQuery, SandboxState @pytest.mark.skip_debug() -def test_kill_existing_sandbox(sandbox: Sandbox): +def test_kill_existing_sandbox(sandbox: Sandbox, sandbox_test_id: str): assert Sandbox.kill(sandbox.sandbox_id) - list = Sandbox.list() - assert sandbox.sandbox_id not in [s.sandbox_id for s in list] + 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() 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 index bab6ed91b4..35e4d5a49a 100644 --- a/packages/python-sdk/tests/sync/api_sync/test_sbx_list.py +++ b/packages/python-sdk/tests/sync/api_sync/test_sbx_list.py @@ -1,30 +1,199 @@ -import random -import string +import time import pytest -from e2b import Sandbox -from e2b.sandbox.sandbox_api import SandboxQuery +from e2b import Sandbox, SandboxQuery, SandboxState @pytest.mark.skip_debug() -def test_list_sandboxes(sandbox: Sandbox): - sandboxes = Sandbox.list() - assert len(sandboxes) > 0 +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(template): - unique_id = "".join(random.choices(string.ascii_letters, k=5)) - Sandbox(template=template, metadata={"unique_id": unique_id}) - sandboxes = Sandbox.list(query=SandboxQuery(metadata={"unique_id": unique_id})) - assert len(sandboxes) == 1 - assert sandboxes[0].metadata["unique_id"] == unique_id +def test_list_sandboxes_with_filter(sandbox_test_id: str): + unique_id = str(int(time.time())) + extra_sbx = Sandbox.create(metadata={"unique_id": unique_id}) + + try: + 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 + finally: + extra_sbx.kill() @pytest.mark.skip_debug() -def test_list_sandboxes_with_empty_filter(sandbox: Sandbox): - sandboxes = Sandbox.list(query=SandboxQuery()) - assert len(sandboxes) > 0 - assert sandbox.sandbox_id in [sbx.sandbox_id for sbx in sandboxes] +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 + paused_sandbox_id = sandbox.sandbox_id.split("-")[0] + assert any( + s.sandbox_id.startswith(paused_sandbox_id) and s.state == SandboxState.PAUSED + for s in sandboxes + ) + + +@pytest.mark.skip_debug() +def test_paginate_running_sandboxes(sandbox: Sandbox, sandbox_test_id: str): + # Create two sandboxes + extra_sbx = Sandbox.create(metadata={"sandbox_test_id": sandbox_test_id}) + + try: + # 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 + finally: + extra_sbx.kill() + + +@pytest.mark.skip_debug() +def test_paginate_paused_sandboxes(sandbox: Sandbox, sandbox_test_id: str): + sandbox_id = sandbox.sandbox_id.split("-")[0] + sandbox.beta_pause() + + # create another paused sandbox + extra_sbx = Sandbox.create(metadata={"sandbox_test_id": sandbox_test_id}) + extra_sbx_id = extra_sbx.sandbox_id.split("-")[0] + extra_sbx.beta_pause() + + try: + # 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.startswith(extra_sbx_id) is True + + # 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.startswith(sandbox_id) is True + finally: + extra_sbx.kill() + + +@pytest.mark.skip_debug() +def test_paginate_running_and_paused_sandboxes(sandbox: Sandbox, sandbox_test_id: str): + # Create extra paused sandbox + extra_sbx = Sandbox.create(metadata={"sandbox_test_id": sandbox_test_id}) + extra_sbx_id = extra_sbx.sandbox_id.split("-")[0] + extra_sbx.beta_pause() + + try: + # 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.startswith(extra_sbx_id) is True + + # 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 + finally: + extra_sbx.kill() + + +@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 0000000000..b87b8d4c5d --- /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.beta_pause(sandbox.sandbox_id) + assert not sandbox.is_running() + + +@pytest.mark.skip_debug() +def test_resume_sandbox(sandbox: Sandbox): + # pause + Sandbox.beta_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_env_vars.py b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_env_vars.py index 897e4a7c3a..eb58e11104 100644 --- 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 @@ -10,7 +10,7 @@ def test_command_envs(sandbox: Sandbox): @pytest.mark.skip_debug() def test_sandbox_envs(template): - sandbox = Sandbox(template, envs={"FOO": "bar"}) + sandbox = Sandbox.create(template, envs={"FOO": "bar"}) try: cmd = sandbox.commands.run("echo $FOO") assert cmd.stdout.strip() == "bar" 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 index df5008ee76..f80f521bad 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/files/test_secured.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_secured.py @@ -8,7 +8,7 @@ @pytest.mark.skip_debug() def test_download_url_with_signing(template): - sbx = Sandbox(template, timeout=100, secure=True) + sbx = Sandbox.create(template, timeout=100, secure=True) file_path = "test_download_url_with_signing.txt" file_content = "This file will be watched." @@ -27,7 +27,7 @@ def test_download_url_with_signing(template): @pytest.mark.skip_debug() def test_download_url_with_signing_and_expiration(template): - sbx = Sandbox(template, timeout=100, secure=True) + sbx = Sandbox.create(template, timeout=100, secure=True) file_path = "test_download_url_with_signing.txt" file_content = "This file will be watched." @@ -46,7 +46,7 @@ def test_download_url_with_signing_and_expiration(template): @pytest.mark.skip_debug() def test_download_url_with_expired_signing(template): - sbx = Sandbox(template, timeout=100, secure=True) + sbx = Sandbox.create(template, timeout=100, secure=True) file_path = "test_download_url_with_signing.txt" file_content = "This file will be watched." 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 index 567e270002..29fc447fba 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/files/test_watch.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_watch.py @@ -112,7 +112,7 @@ def test_watch_file(sandbox: Sandbox): def test_watch_file_with_secured_envd(template): - sbx = Sandbox(template, timeout=30, secure=True) + sbx = Sandbox.create(template, timeout=30, secure=True) try: 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 index 87d6f379f3..8db95c8683 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/files/test_write.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_write.py @@ -127,7 +127,7 @@ def test_write_with_secured_envd(template): filename = f"non_existing_dir_{uuid.uuid4()}/test_write.txt" content = "This should succeed too." - sbx = Sandbox(template, timeout=30, secure=True) + sbx = Sandbox.create(template, timeout=30, secure=True) try: assert sbx.is_running() assert sbx._envd_version is not None diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_connect.py b/packages/python-sdk/tests/sync/sandbox_sync/test_connect.py index d6d9653365..5c71a2ab5c 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_connect.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_connect.py @@ -6,7 +6,7 @@ @pytest.mark.skip_debug() def test_connect(template): - sbx = Sandbox(template, timeout=10) + sbx = Sandbox.create(template, timeout=10) try: assert sbx.is_running() @@ -20,7 +20,7 @@ def test_connect(template): def test_connect_with_secure(template): dir_name = f"test_directory_{uuid.uuid4()}" - sbx = Sandbox(template, timeout=10, secure=True) + sbx = Sandbox.create(template, timeout=10, secure=True) try: assert sbx.is_running() diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_create.py b/packages/python-sdk/tests/sync/sandbox_sync/test_create.py index 56450138fe..08ddef35c4 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_create.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_create.py @@ -1,11 +1,12 @@ import pytest from e2b import Sandbox +from e2b.sandbox.sandbox_api import SandboxQuery @pytest.mark.skip_debug() def test_start(template): - sbx = Sandbox(template, timeout=5) + sbx = Sandbox.create(template, timeout=5) try: assert sbx.is_running() assert sbx._envd_version is not None @@ -15,12 +16,15 @@ def test_start(template): @pytest.mark.skip_debug() def test_metadata(template): - sbx = Sandbox(template, timeout=5, metadata={"test-key": "test-value"}) + sbx = Sandbox.create(template, timeout=5, metadata={"test-key": "test-value"}) try: - sbxs = Sandbox.list() + paginator = Sandbox.list( + query=SandboxQuery(metadata={"test-key": "test-value"}) + ) + sandboxes = paginator.next_items() - for sbx_info in sbxs: + 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" 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 index bbfce8d0da..5e7f4f9e2a 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_internet_access.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_internet_access.py @@ -7,7 +7,7 @@ @pytest.mark.skip_debug() def test_internet_access_enabled(template): """Test that sandbox with internet access enabled can reach external websites.""" - sbx = Sandbox(template, allow_internet_access=True) + sbx = Sandbox.create(template, allow_internet_access=True) try: # Test internet connectivity by making a curl request to a reliable external site result = sbx.commands.run( @@ -22,7 +22,7 @@ def test_internet_access_enabled(template): @pytest.mark.skip_debug() def test_internet_access_disabled(template): """Test that sandbox with internet access disabled cannot reach external websites.""" - sbx = Sandbox(template, allow_internet_access=False) + sbx = Sandbox.create(template, allow_internet_access=False) try: # Test that internet connectivity is blocked by making a curl request with pytest.raises(CommandExitException) as exc_info: @@ -39,7 +39,7 @@ def test_internet_access_disabled(template): @pytest.mark.skip_debug() def test_internet_access_default(template): """Test that sandbox with default settings (no explicit allow_internet_access) has internet access.""" - sbx = Sandbox(template) + sbx = Sandbox.create(template) try: # Test internet connectivity by making a curl request to a reliable external site result = sbx.commands.run( diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_kill.py b/packages/python-sdk/tests/sync/sandbox_sync/test_kill.py index f6ab6e5385..45628e9bd4 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_kill.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_kill.py @@ -1,11 +1,16 @@ import pytest -from e2b import Sandbox +from e2b import Sandbox, SandboxQuery, SandboxState @pytest.mark.skip_debug() -def test_kill(sandbox: Sandbox): +def test_kill(sandbox: Sandbox, sandbox_test_id: str): sandbox.kill() - list = Sandbox.list() - assert sandbox.sandbox_id not in [s.sandbox_id for s in list] + 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 index 03c5eb92ca..9785833bf1 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_metrics.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_metrics.py @@ -6,10 +6,10 @@ @pytest.mark.skip_debug() -def test_sbx_metrics(sandbox: Sandbox): +def test_sbx_metrics(sandbox: Sandbox) -> None: # Wait for the sandbox to have some metrics metrics = [] - for _ in range(10): + for _ in range(15): metrics = sandbox.get_metrics() if len(metrics) > 0: break diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_secure.py b/packages/python-sdk/tests/sync/sandbox_sync/test_secure.py index fe597fef54..95b3b47522 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_secure.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_secure.py @@ -5,7 +5,7 @@ @pytest.mark.skip_debug() def test_start_secured(template): - sbx = Sandbox(template, timeout=5, secure=True) + sbx = Sandbox.create(template, timeout=5, secure=True) try: assert sbx.is_running() assert sbx._envd_version is not None @@ -16,7 +16,7 @@ def test_start_secured(template): @pytest.mark.skip_debug() def test_connect_to_secured(template): - sbx = Sandbox(template, timeout=5, secure=True) + sbx = Sandbox.create(template, timeout=5, secure=True) try: assert sbx.is_running() assert sbx._envd_version 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 0000000000..491f6ffa14 --- /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.beta_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