diff --git a/.changeset/shiny-clients-bind.md b/.changeset/shiny-clients-bind.md new file mode 100644 index 0000000000..b587e7a121 --- /dev/null +++ b/.changeset/shiny-clients-bind.md @@ -0,0 +1,6 @@ +--- +'@e2b/code-interpreter': minor +'@e2b/code-interpreter-python': minor +--- + +Add an `E2B` client to the Code Interpreter SDKs that binds the connection configuration explicitly, so the API key and domain no longer have to come from the environment variables: `new E2B({ apiKey, domain }).Sandbox.create()` in JavaScript and `E2B(api_key=..., domain=...).Sandbox.create()` in Python. The client exposes the package's own `Sandbox` (and `AsyncSandbox` in Python) together with the core `Volume`, `Template` and `Secret` resources, per-call options still take precedence, and multiple clients are isolated from each other and from the env-configured top-level exports. diff --git a/packages/code-interpreter-js/README.md b/packages/code-interpreter-js/README.md index d2ef90eedf..297ac7108d 100644 --- a/packages/code-interpreter-js/README.md +++ b/packages/code-interpreter-js/README.md @@ -45,8 +45,32 @@ const execution = await sbx.runCode('x+=1; x') console.log(execution.text) // outputs 2 ``` -### 4. Check docs +### 4. Bind the configuration to a client + +The top-level `Sandbox` export reads its configuration from the environment variables. To use an explicit configuration — e.g. several API keys or domains in one process — create an `E2B` client and use the resources it exposes: + +```ts +import { E2B } from '@e2b/code-interpreter' + +const client = new E2B({ apiKey: 'e2b_***', domain: 'e2b.dev' }) + +const sbx = await client.Sandbox.create() +const execution = await sbx.runCode('x = 1; x += 1; x') + +// The core resources are bound to the client's configuration as well. +const volume = await client.Volume.create('my-volume') +const exists = await client.Template.exists('my-template') +await client.Secret.create('openai-api-key', 'sk-***') + +// The classes can be destructured and used like the top-level ones. +const { Sandbox } = client +const paginator = Sandbox.list() +``` + +Per-call options still take precedence over the client's options, and clients are isolated from each other and from the env-configured top-level exports. + +### 5. Check docs Visit [E2B documentation](https://docs.e2b.dev/?utm_source=npm&utm_medium=referral&utm_campaign=readme&utm_content=code-interpreter). -### 5. E2B cookbook +### 6. E2B cookbook Visit our [Cookbook](https://github.com/e2b-dev/e2b-cookbook/tree/main) to get inspired by examples with different LLMs and AI frameworks. diff --git a/packages/code-interpreter-js/src/client.ts b/packages/code-interpreter-js/src/client.ts new file mode 100644 index 0000000000..3240b107ec --- /dev/null +++ b/packages/code-interpreter-js/src/client.ts @@ -0,0 +1,93 @@ +import { ConnectionOpts, E2B as CoreE2B, Secret, Template, Volume } from 'e2b' + +import { Sandbox } from './sandbox' + +/** + * Connection options bound to an {@link E2B} client. + * + * Same as {@link ConnectionOpts} without `signal`, which cancels a single + * request and therefore can only be passed per call. + */ +export type E2BClientOpts = Omit + +/** + * E2B client with an explicitly bound connection configuration. + * + * The resources exposed by the client ({@link E2B.Sandbox}, + * {@link E2B.Volume}, {@link E2B.Template}, {@link E2B.Secret}) behave exactly + * like the top-level `Sandbox` / `Volume` / `Template` / `Secret` exports, + * except the options passed to the client are used as the defaults instead of + * the environment variables. + * Per-call options still take precedence over the client's options. + * + * Multiple clients are fully isolated from each other and from the top-level + * env-configured exports. + * + * @example + * ```ts + * import { E2B } from '@e2b/code-interpreter' + * + * const client = new E2B({ apiKey: 'e2b_...', domain: 'e2b.dev' }) + * + * const sandbox = await client.Sandbox.create() + * const execution = await sandbox.runCode('x = 1; x += 1; x') + * ``` + */ +export class E2B { + /** + * Code Interpreter `Sandbox` class bound to this client's connection + * configuration. + */ + readonly Sandbox: typeof Sandbox + + /** + * `Volume` class bound to this client's connection configuration. + */ + readonly Volume: typeof Volume + + /** + * `Template` bound to this client's connection configuration. Both the + * builder (`client.Template()`) and the statics + * (`client.Template.build(...)`, `client.Template.exists(...)`, …) work like + * the top-level `Template`. + */ + readonly Template: typeof Template + + /** + * `Secret` class bound to this client's connection configuration. + */ + readonly Secret: typeof Secret + + /** + * Create a new client with the connection options bound to it. + * + * @param opts connection options used as the defaults for every call made + * through this client's resource classes. + */ + constructor(opts?: E2BClientOpts) { + // Options are copied so later mutations of the caller's object cannot + // change the bound configuration. `signal` is dropped rather than only + // typed away, since it cancels a single request and a caller passing a + // wider-typed object (or plain JS) would otherwise bind it to every call. + const boundOpts: E2BClientOpts = { ...(opts ?? {}) } + delete (boundOpts as ConnectionOpts).signal + + if (boundOpts.headers) { + boundOpts.headers = { ...boundOpts.headers } + } + if (boundOpts.apiHeaders) { + boundOpts.apiHeaders = { ...boundOpts.apiHeaders } + } + + this.Sandbox = class extends Sandbox { + protected static override readonly boundOpts = boundOpts + } + + // The resources that are not specific to the Code Interpreter are bound by + // the core client. + const core = new CoreE2B(boundOpts) + this.Volume = core.Volume + this.Template = core.Template + this.Secret = core.Secret + } +} diff --git a/packages/code-interpreter-js/src/index.ts b/packages/code-interpreter-js/src/index.ts index d459812117..9a309eb502 100644 --- a/packages/code-interpreter-js/src/index.ts +++ b/packages/code-interpreter-js/src/index.ts @@ -1,5 +1,7 @@ export * from 'e2b' +export { E2B } from './client' +export type { E2BClientOpts } from './client' export { Sandbox } from './sandbox' export type { Context, diff --git a/packages/code-interpreter-js/tests/client.test.ts b/packages/code-interpreter-js/tests/client.test.ts new file mode 100644 index 0000000000..a3c5d1c920 --- /dev/null +++ b/packages/code-interpreter-js/tests/client.test.ts @@ -0,0 +1,244 @@ +import { + createServer, + IncomingMessage, + Server, + ServerResponse, +} from 'node:http' +import { AddressInfo } from 'node:net' +import { afterAll, assert, beforeAll, beforeEach, test } from 'vitest' + +import { E2B, Sandbox, Volume } from '../src' + +const API_KEY_A = `e2b_${'a'.repeat(40)}` +const API_KEY_B = `e2b_${'b'.repeat(40)}` +const ENV_API_KEY = `e2b_${'e'.repeat(40)}` + +const DOMAIN_A = 'client-a.test' +const DOMAIN_B = 'client-b.test' +const DOMAIN_ENV = 'env.test' + +interface RecordedRequest { + path: string + apiKey?: string + headers: Record + body: unknown +} + +let requests: RecordedRequest[] = [] +let server: Server +let apiUrl: string + +const lastRequest = () => requests[requests.length - 1] +const apiKeys = () => requests.map((request) => request.apiKey) + +async function handler(req: IncomingMessage, res: ServerResponse) { + const chunks: Buffer[] = [] + for await (const chunk of req) { + chunks.push(chunk as Buffer) + } + const raw = Buffer.concat(chunks).toString() + + requests.push({ + path: req.url ?? '', + apiKey: (req.headers['x-api-key'] as string | undefined) ?? undefined, + headers: { ...req.headers }, + body: raw ? JSON.parse(raw) : undefined, + }) + + const respond = (status: number, body: unknown) => { + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(body)) + } + + const path = req.url ?? '' + if (path.startsWith('/sandboxes')) { + respond(201, { + sandboxID: 'test-sandbox-id', + templateID: 'base', + envdVersion: '0.2.4', + }) + } else if (path.startsWith('/volumes')) { + respond(201, { + volumeID: 'test-volume-id', + name: 'test-volume', + token: 'test-volume-token', + }) + } else if (path.startsWith('/templates/aliases/')) { + respond(200, { aliases: [], templateID: 'test-template-id' }) + } else if (path.startsWith('/secrets')) { + respond(201, { + secretID: 'test-secret-id', + name: 'test-secret', + currentVersion: 1, + metadata: {}, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + } else { + respond(404, { code: 404, message: 'not found' }) + } +} + +const envBackup: Record = {} +const envOverrides: Record = { + E2B_API_KEY: ENV_API_KEY, + E2B_DOMAIN: DOMAIN_ENV, + E2B_API_URL: undefined, + E2B_SANDBOX_URL: undefined, + E2B_DEBUG: undefined, +} + +beforeAll(async () => { + for (const [key, value] of Object.entries(envOverrides)) { + envBackup[key] = process.env[key] + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + + server = createServer(handler) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}` +}) + +afterAll(async () => { + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ) + + for (const [key, value] of Object.entries(envBackup)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +}) + +beforeEach(() => { + requests = [] +}) + +test('client.Sandbox.create uses the client config instead of env vars', async () => { + const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }) + + const sandbox = await client.Sandbox.create() + + assert.deepEqual(apiKeys(), [API_KEY_A]) + assert.equal(sandbox.sandboxDomain, DOMAIN_A) + // The Code Interpreter template is still the default. + assert.equal( + (lastRequest().body as { templateID: string }).templateID, + 'code-interpreter-v1' + ) +}) + +test('client.Sandbox instances keep the Code Interpreter API', async () => { + const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }) + + assert.notEqual(client.Sandbox, Sandbox) + assert.isTrue(client.Sandbox.prototype instanceof Sandbox) + + const sandbox = await client.Sandbox.create() + + assert.instanceOf(sandbox, Sandbox) + assert.instanceOf(sandbox, client.Sandbox) + assert.isFunction(sandbox.runCode) + assert.isFunction(sandbox.createCodeContext) +}) + +test('per-call options take precedence over the client config', async () => { + const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }) + + const sandbox = await client.Sandbox.create({ + apiKey: API_KEY_B, + domain: DOMAIN_B, + }) + + assert.deepEqual(apiKeys(), [API_KEY_B]) + assert.equal(sandbox.sandboxDomain, DOMAIN_B) +}) + +test('per-call options explicitly set to undefined keep the client config', async () => { + const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }) + + const sandbox = await client.Sandbox.create({ + apiKey: undefined, + domain: undefined, + }) + + assert.deepEqual(apiKeys(), [API_KEY_A]) + assert.equal(sandbox.sandboxDomain, DOMAIN_A) +}) + +test('two clients with different configs stay isolated', async () => { + const clientA = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }) + const clientB = new E2B({ apiKey: API_KEY_B, domain: DOMAIN_B, apiUrl }) + + const sandboxA = await clientA.Sandbox.create() + const sandboxB = await clientB.Sandbox.create() + + assert.deepEqual(apiKeys(), [API_KEY_A, API_KEY_B]) + assert.equal(sandboxA.sandboxDomain, DOMAIN_A) + assert.equal(sandboxB.sandboxDomain, DOMAIN_B) +}) + +test('mutating the options object does not change the bound config', async () => { + const opts = { apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl } + const client = new E2B(opts) + opts.apiKey = API_KEY_B + + await client.Sandbox.create() + + assert.deepEqual(apiKeys(), [API_KEY_A]) +}) + +test('mutating the header map does not change the bound config', async () => { + const headers = { 'X-Test': 'bound' } + const client = new E2B({ + apiKey: API_KEY_A, + domain: DOMAIN_A, + apiUrl, + headers, + }) + headers['X-Test'] = 'mutated' + + await client.Sandbox.create() + + assert.equal(lastRequest().headers['x-test'], 'bound') +}) + +test('client.Sandbox can be rebound to a variable', async () => { + const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }) + const S = client.Sandbox + + const sandbox = await S.create() + + assert.deepEqual(apiKeys(), [API_KEY_A]) + assert.equal(sandbox.sandboxDomain, DOMAIN_A) +}) + +test('the core resources are bound to the client config as well', async () => { + const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }) + + const volume = await client.Volume.create('test-volume') + assert.instanceOf(volume, Volume) + assert.deepEqual(apiKeys(), [API_KEY_A]) + + assert.isTrue(await client.Template.exists('test-template')) + await client.Secret.create('test-secret', 'value') + + assert.deepEqual(apiKeys(), [API_KEY_A, API_KEY_A, API_KEY_A]) +}) + +test('the top-level Sandbox keeps using the environment configuration', async () => { + const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }) + await client.Sandbox.create() + + const sandbox = await Sandbox.create({ apiUrl }) + + assert.equal(lastRequest().apiKey, ENV_API_KEY) + assert.equal(sandbox.sandboxDomain, DOMAIN_ENV) +}) diff --git a/packages/code-interpreter-js/tests/runtimes/cloudflare/vitest.config.mts b/packages/code-interpreter-js/tests/runtimes/cloudflare/vitest.config.mts index b5b071caad..2b45ed57d6 100644 --- a/packages/code-interpreter-js/tests/runtimes/cloudflare/vitest.config.mts +++ b/packages/code-interpreter-js/tests/runtimes/cloudflare/vitest.config.mts @@ -2,4 +2,12 @@ import { defineConfig } from 'vitest/config' import { createCloudflareVitestConfig } from '../../../../../vitest.cloudflare.config.mts' -export default defineConfig(createCloudflareVitestConfig()) +export default defineConfig( + createCloudflareVitestConfig({ + exclude: [ + // Serves the mocked API from a local node:http server, which workerd + // cannot listen on; the Node unit project keeps running it. + 'tests/client.test.ts', + ], + }) +) diff --git a/packages/code-interpreter-python/README.md b/packages/code-interpreter-python/README.md index e9016af6fb..9b2fd58c71 100644 --- a/packages/code-interpreter-python/README.md +++ b/packages/code-interpreter-python/README.md @@ -44,8 +44,35 @@ with Sandbox.create() as sandbox: print(execution.text) # outputs 2 ``` -### 4. Check docs +### 4. Bind the configuration to a client + +The top-level `Sandbox` and `AsyncSandbox` exports read their configuration from the environment variables. To use an explicit configuration — e.g. several API keys or domains in one process — create an `E2B` client and use the resource classes it exposes: + +```py +from e2b_code_interpreter import E2B + +client = E2B(api_key="e2b_***", domain="e2b.dev") + +with client.Sandbox.create() as sandbox: + execution = sandbox.run_code("x = 1; x += 1; x") + +# The async variant is exposed as well. +async_sandbox = await client.AsyncSandbox.create() + +# The core resources are bound to the client's configuration as well. +volume = client.Volume.create("my-volume") +exists = client.Template.exists("my-template") +secret = client.Secret.create("openai-api-key", "sk-***") + +# The classes can be assigned and used like the top-level ones. +Sandbox = client.Sandbox +paginator = Sandbox.list() +``` + +Per-call params still take precedence over the client's params, and clients are isolated from each other and from the env-configured top-level exports. + +### 5. Check docs Visit [E2B documentation](https://docs.e2b.dev/?utm_source=pypi&utm_medium=referral&utm_campaign=readme&utm_content=code-interpreter). -### 5. E2B cookbook +### 6. E2B cookbook Visit our [Cookbook](https://github.com/e2b-dev/e2b-cookbook/tree/main) to get inspired by examples with different LLMs and AI frameworks. diff --git a/packages/code-interpreter-python/e2b_code_interpreter/__init__.py b/packages/code-interpreter-python/e2b_code_interpreter/__init__.py index 5202d5debf..94c804a37b 100644 --- a/packages/code-interpreter-python/e2b_code_interpreter/__init__.py +++ b/packages/code-interpreter-python/e2b_code_interpreter/__init__.py @@ -1,4 +1,5 @@ from e2b import * +from .client import E2B, E2BClientParams from .code_interpreter_sync import Sandbox from .code_interpreter_async import AsyncSandbox from .models import ( diff --git a/packages/code-interpreter-python/e2b_code_interpreter/client.py b/packages/code-interpreter-python/e2b_code_interpreter/client.py new file mode 100644 index 0000000000..99c239894f --- /dev/null +++ b/packages/code-interpreter-python/e2b_code_interpreter/client.py @@ -0,0 +1,96 @@ +from typing import Dict, Type, TypeVar, cast + +from e2b import ApiParams +from e2b import E2B as CoreE2B +from typing_extensions import Unpack + +from e2b_code_interpreter.code_interpreter_async import AsyncSandbox +from e2b_code_interpreter.code_interpreter_sync import Sandbox + +T = TypeVar("T") + + +class E2BClientParams(ApiParams, total=False): + """Params bound to an :class:`E2B` client, used as the defaults for every + call made through its resource classes. Same shape as :class:`ApiParams`.""" + + +def _bind(cls: Type[T], api_params: ApiParams) -> Type[T]: + """Generate a subclass of ``cls`` carrying ``api_params`` as its bound params.""" + return cast( + Type[T], + type(cls.__name__, (cls,), {"_bound_api_params": api_params}), + ) + + +class E2B: + """ + E2B client with an explicitly bound connection configuration. + + The resource classes exposed by the client (`Sandbox`, `AsyncSandbox`, + `Volume`, `AsyncVolume`, `Template`, `AsyncTemplate`, `Secret`, + `AsyncSecret`) behave exactly like the top-level exports of the same name, + except the params passed to the client are used as the defaults instead of + the environment variables. + Per-call params still take precedence over the client's params. + + Multiple clients are fully isolated from each other and from the top-level + env-configured exports. + + Example: + ```python + from e2b_code_interpreter import E2B + + client = E2B(api_key="e2b_...", domain="e2b.dev") + + sandbox = client.Sandbox.create() + execution = sandbox.run_code("x = 1; x += 1; x") + ``` + """ + + def __init__(self, **opts: Unpack[E2BClientParams]): + """ + Create a new client with the API params bound to it. + + :param opts: API params used as the defaults for every call made + through this client's resource classes. + """ + # Params are copied so later mutations of the caller's dicts cannot + # change the bound configuration. + api_params = cast(ApiParams, dict(cast(Dict[str, object], opts))) + + headers = api_params.get("headers") + if headers is not None: + api_params["headers"] = dict(headers) + + api_headers = api_params.get("api_headers") + if api_headers is not None: + api_params["api_headers"] = dict(api_headers) + + self.Sandbox = _bind(Sandbox, api_params) + """Code Interpreter `Sandbox` class bound to this client's connection configuration.""" + + self.AsyncSandbox = _bind(AsyncSandbox, api_params) + """Code Interpreter `AsyncSandbox` class bound to this client's connection configuration.""" + + # The resources that are not specific to the Code Interpreter are bound + # by the core client. + core = CoreE2B(**api_params) + + self.Volume = core.Volume + """`Volume` class bound to this client's connection configuration.""" + + self.AsyncVolume = core.AsyncVolume + """`AsyncVolume` class bound to this client's connection configuration.""" + + self.Template = core.Template + """`Template` class bound to this client's connection configuration.""" + + self.AsyncTemplate = core.AsyncTemplate + """`AsyncTemplate` class bound to this client's connection configuration.""" + + self.Secret = core.Secret + """`Secret` class bound to this client's connection configuration.""" + + self.AsyncSecret = core.AsyncSecret + """`AsyncSecret` class bound to this client's connection configuration.""" diff --git a/packages/code-interpreter-python/pyproject.toml b/packages/code-interpreter-python/pyproject.toml index 8506a76d14..188dbb321a 100644 --- a/packages/code-interpreter-python/pyproject.toml +++ b/packages/code-interpreter-python/pyproject.toml @@ -9,7 +9,7 @@ requires-python = ">=3.10" dependencies = [ "httpx>=0.20.0,<1.0.0", "attrs>=21.3.0", - "e2b>=2.39.1,<3.0.0", + "e2b>=2.44.0,<3.0.0", ] [project.urls] diff --git a/packages/code-interpreter-python/tests/test_client.py b/packages/code-interpreter-python/tests/test_client.py new file mode 100644 index 0000000000..d63e5f1c8c --- /dev/null +++ b/packages/code-interpreter-python/tests/test_client.py @@ -0,0 +1,236 @@ +import asyncio +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Dict, List, Tuple + +import pytest +from e2b import AsyncSecret, AsyncTemplate, AsyncVolume, Secret, Template, Volume + +from e2b_code_interpreter import E2B, AsyncSandbox, Sandbox + +API_KEY_A = "e2b_" + "a" * 40 +API_KEY_B = "e2b_" + "b" * 40 +ENV_API_KEY = "e2b_" + "e" * 40 + +DOMAIN_A = "client-a.example.com" +DOMAIN_B = "client-b.example.com" +ENV_DOMAIN = "env.example.com" + +SANDBOX_RESPONSE = { + "templateID": "code-interpreter-v1", + "sandboxID": "sbx-test", + "clientID": "client-test", + "envdVersion": "0.2.0", +} + +SECRET_RESPONSE = { + "secretID": "secret-test", + "name": "secret", + "currentVersion": 1, + "metadata": {}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", +} + + +class _Handler(BaseHTTPRequestHandler): + requests: List[Tuple[str, str, Dict[str, str]]] = [] + + def log_message(self, *args): # noqa: A003 - silence the default stderr log + pass + + def _record_and_respond(self, status: int, body): + type(self).requests.append( + (self.command, self.path, {k.lower(): v for k, v in self.headers.items()}) + ) + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + if length: + self.rfile.read(length) + + if self.path.startswith("/sandboxes"): + self._record_and_respond(201, SANDBOX_RESPONSE) + elif self.path.startswith("/volumes"): + self._record_and_respond( + 201, + {"volumeID": "vol-test", "name": "vol", "token": "vol-token"}, + ) + elif self.path.startswith("/secrets"): + self._record_and_respond(201, SECRET_RESPONSE) + else: + self._record_and_respond(404, {"code": 404, "message": "not found"}) + + def do_GET(self): + if self.path.startswith("/templates/aliases/"): + self._record_and_respond( + 200, + {"aliases": [], "templateID": "tmpl-test", "public": False}, + ) + elif self.path == "/secrets": + self._record_and_respond(200, [SECRET_RESPONSE]) + else: + self._record_and_respond(404, {"code": 404, "message": "not found"}) + + +@pytest.fixture +def api_server(monkeypatch): + """Local API server, with the env config pointed away from any client's.""" + monkeypatch.setenv("E2B_API_KEY", ENV_API_KEY) + monkeypatch.setenv("E2B_DOMAIN", ENV_DOMAIN) + monkeypatch.delenv("E2B_DEBUG", raising=False) + + _Handler.requests = [] + server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + server.server_close() + thread.join() + + +def api_keys() -> List[str]: + return [headers.get("x-api-key", "") for _, _, headers in _Handler.requests] + + +def test_client_sandbox_uses_client_config(api_server): + client = E2B(api_key=API_KEY_A, domain=DOMAIN_A, api_url=api_server) + + sandbox = client.Sandbox.create() + + assert api_keys() == [API_KEY_A] + assert sandbox.connection_config.api_key == API_KEY_A + assert sandbox.connection_config.domain == DOMAIN_A + + +def test_client_sandbox_keeps_the_code_interpreter_api(api_server): + client = E2B(api_key=API_KEY_A, domain=DOMAIN_A, api_url=api_server) + + assert issubclass(client.Sandbox, Sandbox) + assert client.Sandbox is not Sandbox + # Class-level defaults are inherited. + assert client.Sandbox.default_template == Sandbox.default_template + + sandbox = client.Sandbox.create() + + assert isinstance(sandbox, client.Sandbox) + assert isinstance(sandbox, Sandbox) + assert callable(sandbox.run_code) + assert callable(sandbox.create_code_context) + + +def test_per_call_params_override_the_client(api_server): + client = E2B(api_key=API_KEY_A, domain=DOMAIN_A, api_url=api_server) + + sandbox = client.Sandbox.create(api_key=API_KEY_B, domain=DOMAIN_B) + + assert api_keys() == [API_KEY_B] + assert sandbox.connection_config.domain == DOMAIN_B + + +def test_per_call_params_set_to_none_keep_the_client_config(api_server): + client = E2B(api_key=API_KEY_A, domain=DOMAIN_A, api_url=api_server) + + sandbox = client.Sandbox.create(api_key=None, domain=None) + + assert api_keys() == [API_KEY_A] + assert sandbox.connection_config.api_key == API_KEY_A + assert sandbox.connection_config.domain == DOMAIN_A + + +def test_client_class_can_be_rebound(api_server): + client = E2B(api_key=API_KEY_A, domain=DOMAIN_A, api_url=api_server) + + S = client.Sandbox + sandbox = S.create() + + assert api_keys() == [API_KEY_A] + assert sandbox.connection_config.api_key == API_KEY_A + + +def test_two_clients_stay_isolated(api_server): + client_a = E2B(api_key=API_KEY_A, domain=DOMAIN_A, api_url=api_server) + client_b = E2B(api_key=API_KEY_B, domain=DOMAIN_B, api_url=api_server) + + sandbox_a = client_a.Sandbox.create() + sandbox_b = client_b.Sandbox.create() + + assert api_keys() == [API_KEY_A, API_KEY_B] + assert sandbox_a.connection_config.domain == DOMAIN_A + assert sandbox_b.connection_config.domain == DOMAIN_B + + +def test_client_params_are_copied(api_server): + opts = {"api_key": API_KEY_A, "domain": DOMAIN_A, "api_url": api_server} + client = E2B(**opts) + opts["api_key"] = API_KEY_B + + sandbox = client.Sandbox.create() + + assert api_keys() == [API_KEY_A] + assert sandbox.connection_config.api_key == API_KEY_A + + +def test_async_client_sandbox_uses_client_config(api_server): + client = E2B(api_key=API_KEY_A, domain=DOMAIN_A, api_url=api_server) + + async def run(): + return await client.AsyncSandbox.create() + + sandbox = asyncio.run(run()) + + assert issubclass(client.AsyncSandbox, AsyncSandbox) + assert isinstance(sandbox, client.AsyncSandbox) + assert callable(sandbox.run_code) + assert api_keys() == [API_KEY_A] + assert sandbox.connection_config.api_key == API_KEY_A + assert sandbox.connection_config.domain == DOMAIN_A + + +def test_core_resources_are_bound_to_the_client(api_server): + client = E2B(api_key=API_KEY_A, domain=DOMAIN_A, api_url=api_server) + + volume = client.Volume.create("vol") + assert isinstance(volume, Volume) + assert client.Template.exists("tmpl") is True + client.Secret.create("secret", "value") + + assert api_keys() == [API_KEY_A, API_KEY_A, API_KEY_A] + + async def run(): + await client.AsyncVolume.create("vol") + assert await client.AsyncTemplate.exists("tmpl") is True + await client.AsyncSecret.create("secret", "value") + + asyncio.run(run()) + + assert issubclass(client.AsyncVolume, AsyncVolume) + assert issubclass(client.AsyncTemplate, AsyncTemplate) + assert issubclass(client.AsyncSecret, AsyncSecret) + assert issubclass(client.Template, Template) + assert issubclass(client.Secret, Secret) + assert api_keys() == [API_KEY_A] * 6 + + +def test_top_level_classes_keep_using_the_env_config(api_server): + client = E2B(api_key=API_KEY_A, domain=DOMAIN_A, api_url=api_server) + client.Sandbox.create() + + sandbox = Sandbox.create(api_url=api_server) + + assert api_keys()[-1] == ENV_API_KEY + assert sandbox.connection_config.api_key == ENV_API_KEY + assert sandbox.connection_config.domain == ENV_DOMAIN + # Creating clients does not bind anything onto the top-level classes. + assert Sandbox._bound_api_params == {} + assert AsyncSandbox._bound_api_params == {} diff --git a/packages/code-interpreter-python/uv.lock b/packages/code-interpreter-python/uv.lock index 356b53ef9e..c723e7587b 100644 --- a/packages/code-interpreter-python/uv.lock +++ b/packages/code-interpreter-python/uv.lock @@ -358,7 +358,7 @@ wheels = [ [[package]] name = "e2b" -version = "2.46.0" +version = "2.46.1" source = { editable = "../python-sdk" } dependencies = [ { name = "attrs" },