diff --git a/.changeset/python-js-sdk-alignment.md b/.changeset/python-js-sdk-alignment.md new file mode 100644 index 0000000000..1cc4bfbd72 --- /dev/null +++ b/.changeset/python-js-sdk-alignment.md @@ -0,0 +1,25 @@ +--- +"e2b": patch +"@e2b/python-sdk": patch +"@e2b/cli": patch +--- + +fix: align behavior between the JS and Python SDKs + +Python SDK: + +- `commands.send_stdin` and `CommandHandle.send_stdin` now accept `bytes` in addition to `str`, and the handle's `send_stdin` / `close_stdin` now accept a `request_timeout`. +- `git.reset` now accepts a typed `GitResetMode` and its validation error matches the JS SDK wording/ordering. `GitResetMode` is now exported. +- `sandbox_url` is now propagated through `get_api_params`. +- `Template.from_image()` now raises when only one of `username` / `password` is provided. +- `get_info()` no longer carries the envd access token on the returned `SandboxInfo` (the `_envd_access_token` field was unused), matching the JS SDK which strips it from `getInfo`. +- `get_metrics()` now raises `TemplateException` (was `SandboxException`) with the same message as the JS SDK when the sandbox is too old. + +JS SDK: + +- `Sandbox.getInfo()` now includes `sandboxDomain`, matching the Python SDK's single `get_info`. `getFullInfo` is deprecated and now just wraps `getInfo` (it no longer returns the envd access token). +- `Sandbox.getMetrics()` now returns `[]` in debug mode, matching the Python SDK. The debug short-circuit for `getMetrics` / `kill` is implemented on both the instance and static methods, so it applies consistently whether called as `Sandbox.kill(sandboxId)` or `sandbox.kill()`. +- `Template.fromImage()` now requires both `username` and `password` when registry credentials are provided. +- `Template.getBuildStatus()` now defaults `logsOffset` to `0`. +- `requestTimeoutMs: 0` now explicitly disables the request timeout. +- `getMetrics()` now throws `TemplateError` (was `SandboxError`) when the sandbox is too old to support metrics. diff --git a/packages/cli/src/commands/sandbox/info.ts b/packages/cli/src/commands/sandbox/info.ts index 185d650b0e..c15cb08209 100644 --- a/packages/cli/src/commands/sandbox/info.ts +++ b/packages/cli/src/commands/sandbox/info.ts @@ -50,8 +50,7 @@ export const infoCommand = new commander.Command('info') try { const format = options.format || 'pretty' const apiKey = ensureAPIKey() - const info = await Sandbox.getFullInfo(sandboxID, { apiKey }) - delete info.envdAccessToken + const info = await Sandbox.getInfo(sandboxID, { apiKey }) if (format === 'pretty') { renderPrettyInfo(info as unknown as Record) diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index 34d80d32dd..9571dda838 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -102,6 +102,7 @@ export function buildRequestSignal( requestTimeoutMs: number | undefined, userSignal: AbortSignal | undefined ): AbortSignal | undefined { + // `0` (and `undefined`) disable the request timeout. const timeoutSignal = requestTimeoutMs ? AbortSignal.timeout(requestTimeoutMs) : undefined diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index 14e7a2e944..099564e53e 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -810,8 +810,7 @@ export class Filesystem { compareVersions(this.envdApi.version, ENVD_VERSION_RECURSIVE_WATCH) < 0 ) { throw new TemplateError( - 'You need to update the template to use recursive watching. ' + - 'You can do this by running `e2b template build` in the directory with the template.' + 'You need to update the template to use recursive watching.' ) } diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index 9ffe8ae3c1..85790deb94 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -28,7 +28,7 @@ import { } from './sandboxApi' import { getSignature } from './signature' import { compareVersions } from 'compare-versions' -import { SandboxError } from '../errors' +import { TemplateError } from '../errors' import { ENVD_DEBUG_FALLBACK, ENVD_DEFAULT_USER } from '../envd/versions' import { shellQuote } from '../utils' @@ -510,7 +510,7 @@ export class Sandbox extends SandboxApi { */ async kill(opts?: Pick) { if (this.connectionConfig.debug) { - // Skip killing in debug mode + // Skip killing the sandbox in debug mode return } @@ -736,11 +736,15 @@ export class Sandbox extends SandboxApi { * @returns List of sandbox metrics containing CPU, memory and disk usage information. */ async getMetrics(opts?: SandboxMetricsOpts) { + if (this.connectionConfig.debug) { + // Skip getting the metrics in debug mode + return [] + } + if (this.envdApi.version) { if (compareVersions(this.envdApi.version, '0.1.5') < 0) { - throw new 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.' + throw new TemplateError( + 'You need to update the template to use the new SDK.' ) } diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index d2003e36f5..8529de2aef 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -505,6 +505,11 @@ export interface SandboxInfo { * Volume mounts for the sandbox. */ volumeMounts?: Array<{ name: string; path: string }> + + /** + * Sandbox domain. + */ + sandboxDomain?: string } /** @@ -650,6 +655,12 @@ export class SandboxApi { opts?: SandboxApiOpts ): Promise { const config = new ConnectionConfig(opts) + + if (config.debug) { + // Skip killing the sandbox in debug mode + return true + } + const client = new ApiClient(config) const res = await client.api.DELETE('/sandboxes/{sandboxID}', { @@ -685,11 +696,76 @@ export class SandboxApi { sandboxId: string, opts?: SandboxApiOpts ): Promise { - const fullInfo = await this.getFullInfo(sandboxId, opts) - delete fullInfo.envdAccessToken - delete fullInfo.sandboxDomain + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.GET('/sandboxes/{sandboxID}', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), + }) + + if (res.error?.code === 404) { + throw new SandboxNotFoundError(`Sandbox ${sandboxId} not found`) + } + + const err = handleApiError(res) + if (err) { + throw err + } + + if (!res.data) { + throw new Error('Sandbox not found') + } + + return { + sandboxId: res.data.sandboxID, + templateId: res.data.templateID, + ...(res.data.alias && { name: res.data.alias }), + metadata: res.data.metadata ?? {}, + allowInternetAccess: res.data.allowInternetAccess ?? undefined, + envdVersion: res.data.envdVersion, + startedAt: new Date(res.data.startedAt), + endAt: new Date(res.data.endAt), + state: res.data.state, + cpuCount: res.data.cpuCount, + memoryMB: res.data.memoryMB, + network: res.data.network + ? { + allowOut: res.data.network.allowOut, + denyOut: res.data.network.denyOut, + rules: res.data.network.rules ?? undefined, + allowPublicTraffic: res.data.network.allowPublicTraffic, + maskRequestHost: res.data.network.maskRequestHost, + } + : undefined, + lifecycle: res.data.lifecycle + ? { + onTimeout: res.data.lifecycle.onTimeout, + autoResume: res.data.lifecycle.autoResume, + } + : undefined, + sandboxDomain: res.data.domain || undefined, + volumeMounts: res.data.volumeMounts ?? [], + } + } - return fullInfo + /** + * @deprecated Use {@link Sandbox.getInfo} instead. + * + * @param sandboxId sandbox ID. + * @param opts connection options. + * + * @returns sandbox information. + */ + static async getFullInfo( + sandboxId: string, + opts?: SandboxApiOpts + ): Promise { + return await this.getInfo(sandboxId, opts) } /** @@ -705,6 +781,12 @@ export class SandboxApi { opts?: SandboxMetricsOpts ): Promise { const config = new ConnectionConfig(opts) + + if (config.debug) { + // Skip getting the metrics in debug mode + return [] + } + const client = new ApiClient(config) // JS timestamp is in milliseconds, convert to unix (seconds) @@ -723,6 +805,10 @@ export class SandboxApi { signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal), }) + if (res.error?.code === 404) { + throw new SandboxNotFoundError(`Sandbox ${sandboxId} not found`) + } + const err = handleApiError(res) if (err) { throw err @@ -822,65 +908,6 @@ export class SandboxApi { } } - 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, opts?.signal), - }) - - if (res.error?.code === 404) { - throw new SandboxNotFoundError(`Sandbox ${sandboxId} not found`) - } - - const err = handleApiError(res) - if (err) { - throw err - } - - if (!res.data) { - throw new Error('Sandbox not found') - } - - return { - sandboxId: res.data.sandboxID, - templateId: res.data.templateID, - ...(res.data.alias && { name: res.data.alias }), - metadata: res.data.metadata ?? {}, - allowInternetAccess: res.data.allowInternetAccess ?? undefined, - envdVersion: res.data.envdVersion, - 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, - network: res.data.network - ? { - allowOut: res.data.network.allowOut, - denyOut: res.data.network.denyOut, - rules: res.data.network.rules ?? undefined, - allowPublicTraffic: res.data.network.allowPublicTraffic, - maskRequestHost: res.data.network.maskRequestHost, - } - : undefined, - lifecycle: res.data.lifecycle - ? { - onTimeout: res.data.lifecycle.onTimeout, - autoResume: res.data.lifecycle.autoResume, - } - : undefined, - sandboxDomain: res.data.domain || undefined, - volumeMounts: res.data.volumeMounts ?? [], - } - } - /** * Pause the sandbox specified by sandbox ID. * @@ -1074,8 +1101,7 @@ export class SandboxApi { if (compareVersions(res.data!.envdVersion, '0.1.0') < 0) { await this.kill(res.data!.sandboxID, opts) throw new TemplateError( - 'You need to update the template to use the new SDK. ' + - 'You can do this by running `e2b template build` in the directory with the template.' + 'You need to update the template to use the new SDK.' ) } diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index 099e39eb19..8c836e1992 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -1,7 +1,7 @@ import type { PathLike } from 'node:fs' import { ApiClient } from '../api' import { ConnectionConfig, ConnectionOpts } from '../connectionConfig' -import { BuildError } from '../errors' +import { BuildError, InvalidArgumentError } from '../errors' import { runtime } from '../utils' import { assignTags, @@ -270,7 +270,7 @@ export class TemplateBase { templateID: data.templateId, buildID: data.buildId, - logsOffset: options?.logsOffset, + logsOffset: options?.logsOffset ?? 0, }, config.getSignal(undefined, options?.signal) ) @@ -447,6 +447,14 @@ export class TemplateBase baseImage: string, credentials?: { username: string; password: string } ): TemplateBuilder { + // Validate before mutating the builder. + if (credentials && (!credentials.username || !credentials.password)) { + throw new InvalidArgumentError( + 'Both username and password are required when providing registry credentials', + getCallerFrame(STACK_TRACE_DEPTH - 1) + ) + } + this.baseImage = baseImage this.baseTemplate = undefined diff --git a/packages/js-sdk/tests/connectionConfig.test.ts b/packages/js-sdk/tests/connectionConfig.test.ts index f56adf62df..207255d28d 100644 --- a/packages/js-sdk/tests/connectionConfig.test.ts +++ b/packages/js-sdk/tests/connectionConfig.test.ts @@ -176,6 +176,31 @@ test('getSignal returns undefined when no timeout and no signal', () => { assert.equal(signal, undefined) }) +test('requestTimeoutMs 0 from the config disables the timeout', () => { + const config = new ConnectionConfig({ requestTimeoutMs: 0 }) + // The stored value is kept as 0 (not replaced by the default). + assert.equal(config.requestTimeoutMs, 0) + // getSignal() with no per-call arg falls back to the stored 0, which must + // NOT produce a timeout signal. + assert.equal(config.getSignal(), undefined) + // With only a user signal, no timeout signal is layered on top. + const controller = new AbortController() + assert.strictEqual( + config.getSignal(undefined, controller.signal), + controller.signal + ) +}) + +test('setupRequestController with config timeout 0 never auto-aborts', async () => { + const config = new ConnectionConfig({ requestTimeoutMs: 0 }) + const { controller } = setupRequestController( + config.requestTimeoutMs, + undefined + ) + await new Promise((resolve) => setTimeout(resolve, 40)) + assert.equal(controller.signal.aborted, false) +}) + test('setupRequestController aborts when user signal aborts', () => { const userController = new AbortController() const { controller } = setupRequestController(0, userController.signal) diff --git a/packages/js-sdk/tests/sandbox/commands/sendStdin.test.ts b/packages/js-sdk/tests/sandbox/commands/sendStdin.test.ts index a08b6ad1b8..da0831c92d 100644 --- a/packages/js-sdk/tests/sandbox/commands/sendStdin.test.ts +++ b/packages/js-sdk/tests/sandbox/commands/sendStdin.test.ts @@ -22,6 +22,27 @@ sandboxTest('send stdin to process', async ({ sandbox }) => { assert.equal(cmd.stdout, text) }) +sandboxTest('send Uint8Array stdin to process', async ({ sandbox }) => { + const text = 'Hello, World!' + const cmd = await sandbox.commands.run('cat', { + background: true, + stdin: true, + }) + + await sandbox.commands.sendStdin(cmd.pid, new TextEncoder().encode(text)) + + for (let i = 0; i < 5; i++) { + if (cmd.stdout === text) { + break + } + await new Promise((r) => setTimeout(r, 500)) + } + + await cmd.kill() + + assert.equal(cmd.stdout, text) +}) + sandboxTest('send stdin via command handle', async ({ sandbox }) => { const text = 'Hello, World!' const cmd = await sandbox.commands.run('cat', { diff --git a/packages/js-sdk/tests/sandbox/git/validation.test.ts b/packages/js-sdk/tests/sandbox/git/validation.test.ts new file mode 100644 index 0000000000..24bd2a9190 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/validation.test.ts @@ -0,0 +1,44 @@ +import { test, expect } from 'vitest' + +import { Git } from '../../../src/sandbox/git' +import type { Commands } from '../../../src/sandbox/commands' +import { InvalidArgumentError } from '../../../src/errors' + +// Stub command runner that fails if a git command is actually executed — +// validation must throw before reaching it. +const failingCommands = { + run: () => { + throw new Error('commands.run should not be called') + }, +} as unknown as Commands + +test('git.reset throws InvalidArgumentError on an invalid mode', async () => { + const git = new Git(failingCommands) + await expect( + // @ts-expect-error - testing runtime validation with an invalid mode + git.reset('/repo', { mode: 'bogus' }) + ).rejects.toThrow(InvalidArgumentError) +}) + +test('git.reset accepts a valid mode', async () => { + const git = new Git(failingCommands) + // A valid mode must pass validation and reach the (stubbed) command runner. + await expect(git.reset('/repo', { mode: 'hard' })).rejects.toThrow( + 'commands.run should not be called' + ) +}) + +test('git.remoteAdd throws InvalidArgumentError when name or url is missing', async () => { + const git = new Git(failingCommands) + await expect( + git.remoteAdd('/repo', '', 'https://example.com') + ).rejects.toThrow(InvalidArgumentError) + await expect(git.remoteAdd('/repo', 'origin', '')).rejects.toThrow( + InvalidArgumentError + ) +}) + +test('git.remoteGet throws InvalidArgumentError when name is missing', async () => { + const git = new Git(failingCommands) + await expect(git.remoteGet('/repo', '')).rejects.toThrow(InvalidArgumentError) +}) diff --git a/packages/js-sdk/tests/template/stacktrace.test.ts b/packages/js-sdk/tests/template/stacktrace.test.ts index 65d1583389..3d6fbec0d5 100644 --- a/packages/js-sdk/tests/template/stacktrace.test.ts +++ b/packages/js-sdk/tests/template/stacktrace.test.ts @@ -194,6 +194,13 @@ buildTemplateTest('traces on fromGCPRegistry', async ({ buildTemplate }) => { }, 'fromGCPRegistry') }) +buildTemplateTest('traces on fromImage credentials', async () => { + await expectToThrowAndCheckTrace(async () => { + // @ts-expect-error - testing runtime validation with partial credentials + Template().fromImage('ubuntu:22.04', { username: 'user' }) + }, 'fromImage') +}) + buildTemplateTest('traces on copy', async ({ buildTemplate }) => { let template = Template().fromBaseImage() template = template.skipCache().copy(nonExistentPath, nonExistentPath) diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index d574de891a..ca46170fd4 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -67,7 +67,7 @@ FilesystemEvent, FilesystemEventType, ) -from .sandbox._git import GitBranches, GitFileStatus, GitStatus +from .sandbox._git import GitBranches, GitFileStatus, GitResetMode, GitStatus from .sandbox_sync.git import Git from .sandbox.network import ALL_TRAFFIC from .sandbox.signature import get_signature @@ -176,6 +176,7 @@ "GitStatus", "GitBranches", "GitFileStatus", + "GitResetMode", # Command handle "CommandResult", "Stderr", diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index e24cedad27..47b13f101b 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -1,6 +1,6 @@ import os -from typing import Optional, Dict, TypedDict +from typing import cast, Optional, Dict, TypedDict from httpx._types import ProxyTypes from typing_extensions import Unpack @@ -195,6 +195,7 @@ def get_api_params( domain = opts.get("domain") debug = opts.get("debug") proxy = opts.get("proxy") + sandbox_url = opts.get("sandbox_url") req_headers = self.headers.copy() if headers is not None: @@ -211,6 +212,9 @@ def get_api_params( request_timeout=self.get_request_timeout(request_timeout), headers=req_headers, proxy=proxy if proxy is not None else self.proxy, + sandbox_url=sandbox_url + if sandbox_url is not None + else cast(Optional[str], self._sandbox_url), ) ) diff --git a/packages/python-sdk/e2b/sandbox/_git/__init__.py b/packages/python-sdk/e2b/sandbox/_git/__init__.py index 8c005e0f62..e62ad9f6ca 100644 --- a/packages/python-sdk/e2b/sandbox/_git/__init__.py +++ b/packages/python-sdk/e2b/sandbox/_git/__init__.py @@ -36,7 +36,13 @@ parse_git_status, parse_remote_url, ) -from e2b.sandbox._git.types import ClonePlan, GitBranches, GitFileStatus, GitStatus +from e2b.sandbox._git.types import ( + ClonePlan, + GitBranches, + GitFileStatus, + GitResetMode, + GitStatus, +) __all__ = [ "build_add_args", @@ -74,5 +80,6 @@ "ClonePlan", "GitBranches", "GitFileStatus", + "GitResetMode", "GitStatus", ] diff --git a/packages/python-sdk/e2b/sandbox/_git/args.py b/packages/python-sdk/e2b/sandbox/_git/args.py index 0eeef13a0f..9d214b2e71 100644 --- a/packages/python-sdk/e2b/sandbox/_git/args.py +++ b/packages/python-sdk/e2b/sandbox/_git/args.py @@ -267,10 +267,10 @@ def build_reset_args( """ Build arguments for a git reset command. """ - allowed_modes = {"soft", "mixed", "hard", "merge", "keep"} + allowed_modes = ["soft", "mixed", "hard", "merge", "keep"] if mode and mode not in allowed_modes: raise InvalidArgumentException( - f"Reset mode must be one of {', '.join(sorted(allowed_modes))}." + f"Reset mode must be one of {', '.join(allowed_modes)}." ) args = ["reset"] diff --git a/packages/python-sdk/e2b/sandbox/_git/types.py b/packages/python-sdk/e2b/sandbox/_git/types.py index 2ddb70f294..35006e2108 100644 --- a/packages/python-sdk/e2b/sandbox/_git/types.py +++ b/packages/python-sdk/e2b/sandbox/_git/types.py @@ -1,6 +1,11 @@ from dataclasses import dataclass from typing import List, Optional +from typing_extensions import Literal + +GitResetMode = Literal["soft", "mixed", "hard", "merge", "keep"] +"""Mode for a git reset operation.""" + @dataclass class GitFileStatus: diff --git a/packages/python-sdk/e2b/sandbox/main.py b/packages/python-sdk/e2b/sandbox/main.py index 813698bf41..85ecc0fd52 100644 --- a/packages/python-sdk/e2b/sandbox/main.py +++ b/packages/python-sdk/e2b/sandbox/main.py @@ -14,7 +14,6 @@ class SandboxOpts(TypedDict): sandbox_domain: Optional[str] envd_version: Version envd_access_token: Optional[str] - sandbox_url: Optional[str] traffic_access_token: Optional[str] connection_config: ConnectionConfig diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 530bf506ca..ec97b0f9e8 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -439,8 +439,6 @@ class SandboxInfo: """Sandbox Memory size in MiB.""" envd_version: str """Envd version.""" - _envd_access_token: Optional[str] - """Envd access token.""" allow_internet_access: Optional[bool] = None """Whether internet access was explicitly enabled or disabled for the sandbox.""" network: Optional[SandboxNetworkInfo] = None @@ -454,7 +452,6 @@ class SandboxInfo: def _from_sandbox_data( cls, sandbox: Union[ListedSandbox, SandboxDetail], - envd_access_token: Optional[str] = None, sandbox_domain: Optional[str] = None, allow_internet_access: Optional[bool] = None, network: Optional[SandboxNetworkInfo] = None, @@ -480,7 +477,6 @@ def _from_sandbox_data( ] if not isinstance(sandbox.volume_mounts, Unset) else [], - _envd_access_token=envd_access_token, allow_internet_access=allow_internet_access, network=network, lifecycle=lifecycle, @@ -494,11 +490,6 @@ def _from_listed_sandbox(cls, listed_sandbox: ListedSandbox): def _from_sandbox_detail(cls, sandbox_detail: SandboxDetail): return cls._from_sandbox_data( sandbox_detail, - ( - sandbox_detail.envd_access_token - if isinstance(sandbox_detail.envd_access_token, str) - else None - ), sandbox_domain=( sandbox_detail.domain if isinstance(sandbox_detail.domain, str) diff --git a/packages/python-sdk/e2b/sandbox_async/commands/command.py b/packages/python-sdk/e2b/sandbox_async/commands/command.py index 227e778f1a..1280d0fc89 100644 --- a/packages/python-sdk/e2b/sandbox_async/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_async/commands/command.py @@ -108,7 +108,7 @@ async def kill( async def send_stdin( self, pid: int, - data: str, + data: Union[str, bytes], request_timeout: Optional[float] = None, ) -> None: """ @@ -123,7 +123,7 @@ async def send_stdin( process_pb2.SendInputRequest( process=process_pb2.ProcessSelector(pid=pid), input=process_pb2.ProcessInput( - stdin=data.encode(), + stdin=data.encode() if isinstance(data, str) else data, ), ), request_timeout=self._connection_config.get_request_timeout( @@ -312,8 +312,12 @@ async def _start( events=events, on_stdout=on_stdout, on_stderr=on_stderr, - handle_send_stdin=lambda data: self.send_stdin(pid, data), - handle_close_stdin=lambda: self.close_stdin(pid), + handle_send_stdin=lambda data, request_timeout=None: self.send_stdin( + pid, data, request_timeout + ), + handle_close_stdin=lambda request_timeout=None: self.close_stdin( + pid, request_timeout + ), ) except Exception as e: raise handle_rpc_exception(e) @@ -366,8 +370,12 @@ async def connect( events=events, on_stdout=on_stdout, on_stderr=on_stderr, - handle_send_stdin=lambda data: self.send_stdin(pid, data), - handle_close_stdin=lambda: self.close_stdin(pid), + handle_send_stdin=lambda data, request_timeout=None: self.send_stdin( + pid, data, request_timeout + ), + handle_close_stdin=lambda request_timeout=None: self.close_stdin( + pid, request_timeout + ), ) except Exception as e: raise handle_rpc_exception(e) diff --git a/packages/python-sdk/e2b/sandbox_async/commands/command_handle.py b/packages/python-sdk/e2b/sandbox_async/commands/command_handle.py index 3a8c0ae8ef..e74ab6ce7f 100644 --- a/packages/python-sdk/e2b/sandbox_async/commands/command_handle.py +++ b/packages/python-sdk/e2b/sandbox_async/commands/command_handle.py @@ -83,8 +83,12 @@ def __init__( on_stdout: Optional[OutputHandler[Stdout]] = None, on_stderr: Optional[OutputHandler[Stderr]] = None, on_pty: Optional[OutputHandler[PtyOutput]] = None, - handle_send_stdin: Optional[Callable[[str], Coroutine[Any, Any, None]]] = None, - handle_close_stdin: Optional[Callable[[], Coroutine[Any, Any, None]]] = None, + handle_send_stdin: Optional[ + Callable[[Union[str, bytes], Optional[float]], Coroutine[Any, Any, None]] + ] = None, + handle_close_stdin: Optional[ + Callable[[Optional[float]], Coroutine[Any, Any, None]] + ] = None, ): self._pid = pid self._handle_kill = handle_kill @@ -203,28 +207,35 @@ async def kill(self) -> bool: result = await self._handle_kill() return result - async def send_stdin(self, data: str) -> None: + async def send_stdin( + self, + data: Union[str, bytes], + request_timeout: Optional[float] = None, + ) -> None: """ Send data to the command stdin. The command must have been started with `stdin=True`. :param data: Data to send to the command + :param request_timeout: Timeout for the request in **seconds** """ if self._handle_send_stdin is None: raise SandboxException( "Sending stdin is not supported for this command handle." ) - await self._handle_send_stdin(data) + await self._handle_send_stdin(data, request_timeout) - async def close_stdin(self) -> None: + async def close_stdin(self, request_timeout: Optional[float] = None) -> None: """ Close the command stdin. This signals EOF to the command. The command must have been started with `stdin=True`. + + :param request_timeout: Timeout for the request in **seconds** """ if self._handle_close_stdin is None: raise SandboxException( "Closing stdin is not supported for this command handle." ) - await self._handle_close_stdin() + await self._handle_close_stdin(request_timeout) diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py index 933d965083..77b11f64cd 100644 --- a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py @@ -613,8 +613,7 @@ async def watch_dir( """ if recursive and self._envd_version < ENVD_VERSION_RECURSIVE_WATCH: raise TemplateException( - "You need to update the template to use recursive watching. " - "You can do this by running `e2b template build` in the directory with the template." + "You need to update the template to use recursive watching." ) events = self._rpc.awatch_dir( diff --git a/packages/python-sdk/e2b/sandbox_async/git.py b/packages/python-sdk/e2b/sandbox_async/git.py index a63faef5a0..b57fb5a76e 100644 --- a/packages/python-sdk/e2b/sandbox_async/git.py +++ b/packages/python-sdk/e2b/sandbox_async/git.py @@ -8,6 +8,7 @@ from e2b.sandbox.commands.command_handle import CommandExitException from e2b.sandbox._git import ( GitBranches, + GitResetMode, GitStatus, build_add_args, build_auth_error_message, @@ -661,7 +662,7 @@ async def commit( async def reset( self, path: str, - mode: Optional[str] = None, + mode: Optional[GitResetMode] = None, target: Optional[str] = None, paths: Optional[List[str]] = None, envs: Optional[Dict[str, str]] = None, diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index d0bafd0045..a09ea33364 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -15,7 +15,7 @@ from e2b.envd.api import ENVD_API_HEALTH_ROUTE, ahandle_envd_api_exception from e2b.envd.versions import ENVD_DEBUG_FALLBACK from e2b.exceptions import ( - SandboxException, + TemplateException, format_request_timeout_error, ) from e2b.sandbox.main import SandboxOpts @@ -377,6 +377,10 @@ async def kill( :return: `True` if the sandbox was killed, `False` if the sandbox was not found """ + if self.connection_config.debug: + # Skip killing the sandbox in debug mode + return True + return await SandboxApi._cls_kill( sandbox_id=self.sandbox_id, **self.connection_config.get_api_params(**opts), @@ -583,9 +587,13 @@ async def get_metrics( :return: List of sandbox metrics containing CPU, memory and disk usage information """ + if self.connection_config.debug: + # Skip getting the metrics in debug mode + return [] + if self._envd_version < Version("0.1.5"): - raise SandboxException( - "Metrics are not supported in this version of the sandbox, please rebuild your template." + raise TemplateException( + "You need to update the template to use the new SDK." ) if self._envd_version < Version("0.2.4"): @@ -663,6 +671,8 @@ async def beta_pause( ) -> bool: """ :deprecated: Use `pause()` instead. + + :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ return await self.pause(**opts) diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 180630c074..b40119718b 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -243,8 +243,7 @@ async def _create_sandbox( if Version(res.parsed.envd_version) < Version("0.1.0"): await SandboxApi._cls_kill(res.parsed.sandbox_id) raise TemplateException( - "You need to update the template to use the new SDK. " - "You can do this by running `e2b template build` in the directory with the template." + "You need to update the template to use the new SDK." ) domain = res.parsed.domain if isinstance(res.parsed.domain, str) else None @@ -298,6 +297,9 @@ async def _cls_get_metrics( client=api_client, ) + if res.status_code == 404: + raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") + if res.status_code >= 300: raise handle_api_exception(res) diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/command.py b/packages/python-sdk/e2b/sandbox_sync/commands/command.py index feafd6c5d2..1c3f53c5e1 100644 --- a/packages/python-sdk/e2b/sandbox_sync/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_sync/commands/command.py @@ -107,7 +107,7 @@ def kill( def send_stdin( self, pid: int, - data: str, + data: Union[str, bytes], request_timeout: Optional[float] = None, ): """ @@ -122,7 +122,7 @@ def send_stdin( process_pb2.SendInputRequest( process=process_pb2.ProcessSelector(pid=pid), input=process_pb2.ProcessInput( - stdin=data.encode(), + stdin=data.encode() if isinstance(data, str) else data, ), ), request_timeout=self._connection_config.get_request_timeout( @@ -310,8 +310,12 @@ def _start( pid=pid, handle_kill=lambda: self.kill(pid), events=events, - handle_send_stdin=lambda data: self.send_stdin(pid, data), - handle_close_stdin=lambda: self.close_stdin(pid), + handle_send_stdin=lambda data, request_timeout=None: self.send_stdin( + pid, data, request_timeout + ), + handle_close_stdin=lambda request_timeout=None: self.close_stdin( + pid, request_timeout + ), ) except Exception as e: raise handle_rpc_exception(e) @@ -358,8 +362,12 @@ def connect( pid=pid, handle_kill=lambda: self.kill(pid), events=events, - handle_send_stdin=lambda data: self.send_stdin(pid, data), - handle_close_stdin=lambda: self.close_stdin(pid), + handle_send_stdin=lambda data, request_timeout=None: self.send_stdin( + pid, data, request_timeout + ), + handle_close_stdin=lambda request_timeout=None: self.close_stdin( + pid, request_timeout + ), ) except Exception as e: raise handle_rpc_exception(e) diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/command_handle.py b/packages/python-sdk/e2b/sandbox_sync/commands/command_handle.py index 7a4599bccd..f9b4f6af0e 100644 --- a/packages/python-sdk/e2b/sandbox_sync/commands/command_handle.py +++ b/packages/python-sdk/e2b/sandbox_sync/commands/command_handle.py @@ -33,8 +33,10 @@ def __init__( events: Generator[ Union[process_pb2.StartResponse, process_pb2.ConnectResponse], Any, None ], - handle_send_stdin: Optional[Callable[[str], None]] = None, - handle_close_stdin: Optional[Callable[[], None]] = None, + handle_send_stdin: Optional[ + Callable[[Union[str, bytes], Optional[float]], None] + ] = None, + handle_close_stdin: Optional[Callable[[Optional[float]], None]] = None, ): self._pid = pid self._handle_kill = handle_kill @@ -154,28 +156,35 @@ def kill(self) -> bool: """ return self._handle_kill() - def send_stdin(self, data: str) -> None: + def send_stdin( + self, + data: Union[str, bytes], + request_timeout: Optional[float] = None, + ) -> None: """ Send data to the command stdin. The command must have been started with `stdin=True`. :param data: Data to send to the command + :param request_timeout: Timeout for the request in **seconds** """ if self._handle_send_stdin is None: raise SandboxException( "Sending stdin is not supported for this command handle." ) - self._handle_send_stdin(data) + self._handle_send_stdin(data, request_timeout) - def close_stdin(self) -> None: + def close_stdin(self, request_timeout: Optional[float] = None) -> None: """ Close the command stdin. This signals EOF to the command. The command must have been started with `stdin=True`. + + :param request_timeout: Timeout for the request in **seconds** """ if self._handle_close_stdin is None: raise SandboxException( "Closing stdin is not supported for this command handle." ) - self._handle_close_stdin() + self._handle_close_stdin(request_timeout) diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py index ca345a2aba..a41046b30c 100644 --- a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py @@ -598,8 +598,7 @@ def watch_dir( """ if recursive and self._envd_version < ENVD_VERSION_RECURSIVE_WATCH: raise TemplateException( - "You need to update the template to use recursive watching. " - "You can do this by running `e2b template build` in the directory with the template." + "You need to update the template to use recursive watching." ) try: diff --git a/packages/python-sdk/e2b/sandbox_sync/git.py b/packages/python-sdk/e2b/sandbox_sync/git.py index a6d49da99e..39edb5c04c 100644 --- a/packages/python-sdk/e2b/sandbox_sync/git.py +++ b/packages/python-sdk/e2b/sandbox_sync/git.py @@ -2,6 +2,7 @@ from e2b.sandbox._git import ( GitBranches, + GitResetMode, GitStatus, build_add_args, build_auth_error_message, @@ -647,7 +648,7 @@ def commit( def reset( self, path: str, - mode: Optional[str] = None, + mode: Optional[GitResetMode] = None, target: Optional[str] = None, paths: Optional[List[str]] = None, envs: Optional[Dict[str, str]] = None, diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index c8d3177c22..d322c8893f 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -15,7 +15,7 @@ from e2b.envd.api import ENVD_API_HEALTH_ROUTE, handle_envd_api_exception from e2b.envd.versions import ENVD_DEBUG_FALLBACK from e2b.exceptions import ( - SandboxException, + TemplateException, format_request_timeout_error, ) from e2b.sandbox.main import SandboxOpts @@ -376,6 +376,10 @@ def kill( :return: `True` if the sandbox was killed, `False` if the sandbox was not found """ + if self.connection_config.debug: + # Skip killing the sandbox in debug mode + return True + return SandboxApi._cls_kill( sandbox_id=self.sandbox_id, **self.connection_config.get_api_params(**opts), @@ -585,9 +589,13 @@ def get_metrics( :return: List of sandbox metrics containing CPU, memory and disk usage information """ + if self.connection_config.debug: + # Skip getting the metrics in debug mode + return [] + if self._envd_version < Version("0.1.5"): - raise SandboxException( - "Metrics are not supported in this version of the sandbox, please rebuild your template." + raise TemplateException( + "You need to update the template to use the new SDK." ) if self._envd_version < Version("0.2.4"): @@ -665,6 +673,8 @@ def beta_pause( ) -> bool: """ :deprecated: Use `pause()` instead. + + :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ return self.pause(**opts) diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index ed9cd2d4ce..813c74e630 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -242,8 +242,7 @@ def _create_sandbox( if Version(res.parsed.envd_version) < Version("0.1.0"): SandboxApi._cls_kill(res.parsed.sandbox_id) raise TemplateException( - "You need to update the template to use the new SDK. " - "You can do this by running `e2b template build` in the directory with the template." + "You need to update the template to use the new SDK." ) domain = res.parsed.domain if isinstance(res.parsed.domain, str) else None @@ -288,6 +287,9 @@ def _cls_get_metrics( client=api_client, ) + if res.status_code == 404: + raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") + if res.status_code >= 300: raise handle_api_exception(res) diff --git a/packages/python-sdk/e2b/template/main.py b/packages/python-sdk/e2b/template/main.py index f411db1081..5d0c74e404 100644 --- a/packages/python-sdk/e2b/template/main.py +++ b/packages/python-sdk/e2b/template/main.py @@ -3,7 +3,7 @@ from pathlib import Path -from e2b.exceptions import BuildException +from e2b.exceptions import BuildException, InvalidArgumentException from e2b.template.consts import STACK_TRACE_DEPTH, RESOLVE_SYMLINKS from e2b.template.dockerfile_parser import parse_dockerfile from e2b.template.readycmd import ReadyCmd, wait_for_file @@ -1004,17 +1004,24 @@ def from_image( Template().from_image('myregistry.com/myimage:latest', username='user', password='pass') ``` """ - self._base_image = image - self._base_template = None + # Validate (and resolve the registry config) before mutating the builder. + if username is not None or password is not None: + if not username or not password: + caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) + stack_trace = make_traceback(caller_frame) + raise InvalidArgumentException( + "Both username and password are required when providing registry credentials" + ).with_traceback(stack_trace) - # Set the registry config if provided - if username and password: self._registry_config = { "type": "registry", "username": username, "password": password, } + self._base_image = image + self._base_template = None + # If we should force the next layer and it's a FROM command, invalidate whole template if self._force_next_layer: self._force = True diff --git a/packages/python-sdk/tests/async/sandbox_async/commands/test_send_stdin.py b/packages/python-sdk/tests/async/sandbox_async/commands/test_send_stdin.py index 839857e2c8..58c4c948ec 100644 --- a/packages/python-sdk/tests/async/sandbox_async/commands/test_send_stdin.py +++ b/packages/python-sdk/tests/async/sandbox_async/commands/test_send_stdin.py @@ -19,6 +19,22 @@ def handle_event(stdout: str): assert cmd.stdout == "Hello, World!" +async def test_send_bytes_stdin_to_process(async_sandbox: AsyncSandbox): + ev = asyncio.Event() + + def handle_event(stdout: str): + ev.set() + + cmd = await async_sandbox.commands.run( + "cat", background=True, on_stdout=handle_event, stdin=True + ) + await async_sandbox.commands.send_stdin(cmd.pid, b"Hello, World!") + + await ev.wait() + + assert cmd.stdout == "Hello, World!" + + async def test_send_stdin_via_command_handle(async_sandbox: AsyncSandbox): ev = asyncio.Event() diff --git a/packages/python-sdk/tests/async/template_async/test_stacktrace.py b/packages/python-sdk/tests/async/template_async/test_stacktrace.py index 03bc2ca162..b1e8bcbb76 100644 --- a/packages/python-sdk/tests/async/template_async/test_stacktrace.py +++ b/packages/python-sdk/tests/async/template_async/test_stacktrace.py @@ -131,6 +131,14 @@ async def test_traces_on_from_image_registry(async_build): ) +@pytest.mark.skip_debug() +async def test_traces_on_from_image_credentials(): + await _expect_to_throw_and_check_trace( + lambda: AsyncTemplate().from_image("ubuntu:22.04", username="user"), + "from_image", + ) + + @pytest.mark.skip_debug() async def test_traces_on_from_aws_registry(async_build): template = AsyncTemplate().from_aws_registry( diff --git a/packages/python-sdk/tests/shared/git/test_args.py b/packages/python-sdk/tests/shared/git/test_args.py new file mode 100644 index 0000000000..81fe8b7639 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_args.py @@ -0,0 +1,31 @@ +import pytest + +from e2b.exceptions import InvalidArgumentException +from e2b.sandbox._git import ( + build_remote_add_args, + build_remote_get_command, + build_reset_args, +) + + +def test_build_reset_args_rejects_invalid_mode(): + with pytest.raises(InvalidArgumentException) as exc: + build_reset_args("bogus", None, None) + # Order must match the JS SDK exactly. + assert "Reset mode must be one of soft, mixed, hard, merge, keep." in str(exc.value) + + +def test_build_reset_args_accepts_valid_mode(): + assert build_reset_args("hard", "HEAD", None) == ["reset", "--hard", "HEAD"] + + +def test_build_remote_add_args_requires_name_and_url(): + with pytest.raises(InvalidArgumentException): + build_remote_add_args("", "https://example.com", False) + with pytest.raises(InvalidArgumentException): + build_remote_add_args("origin", "", False) + + +def test_build_remote_get_command_requires_name(): + with pytest.raises(InvalidArgumentException): + build_remote_get_command("/repo", "") diff --git a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_send_stdin.py b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_send_stdin.py index 0b04ec7876..db5a323987 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_send_stdin.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_send_stdin.py @@ -10,6 +10,15 @@ def test_send_stdin_to_process(sandbox: Sandbox): break +def test_send_bytes_stdin_to_process(sandbox: Sandbox): + cmd = sandbox.commands.run("cat", background=True, stdin=True) + sandbox.commands.send_stdin(cmd.pid, b"Hello, World!") + + for stdout, _, _ in cmd: + assert stdout == "Hello, World!" + break + + def test_send_stdin_via_command_handle(sandbox: Sandbox): cmd = sandbox.commands.run("cat", background=True, stdin=True) cmd.send_stdin("Hello, World!") diff --git a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py index c2dd79028f..aed121b3fe 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py +++ b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py @@ -133,6 +133,14 @@ def test_traces_on_from_image_registry(build): ) +@pytest.mark.skip_debug() +def test_traces_on_from_image_credentials(): + _expect_to_throw_and_check_trace( + lambda: Template().from_image("ubuntu:22.04", username="user"), + "from_image", + ) + + @pytest.mark.skip_debug() def test_traces_on_from_aws_registry(build): template = Template() diff --git a/packages/python-sdk/tests/test_connection_config.py b/packages/python-sdk/tests/test_connection_config.py index 85f108fe8b..c866518b52 100644 --- a/packages/python-sdk/tests/test_connection_config.py +++ b/packages/python-sdk/tests/test_connection_config.py @@ -94,3 +94,22 @@ def test_sandbox_direct_url_uses_explicit_url_first(): config.get_sandbox_direct_url("sandbox-id", "e2b.app") == "https://sandbox.example.com" ) + + +def test_request_timeout_zero_means_no_timeout(): + config = ConnectionConfig(request_timeout=0) + assert config.request_timeout is None + assert config.get_request_timeout() is None + # A per-call value of 0 also disables the timeout. + assert config.get_request_timeout(0) is None + + +def test_get_api_params_includes_sandbox_url(): + config = ConnectionConfig(sandbox_url="https://sandbox.example.com") + + params = config.get_api_params() + assert params["sandbox_url"] == "https://sandbox.example.com" + + # Per-call override takes priority. + overridden = config.get_api_params(sandbox_url="https://sandbox.override.com") + assert overridden["sandbox_url"] == "https://sandbox.override.com"