diff --git a/.changeset/shiny-clients-bind.md b/.changeset/shiny-clients-bind.md new file mode 100644 index 0000000000..4b827f2a26 --- /dev/null +++ b/.changeset/shiny-clients-bind.md @@ -0,0 +1,12 @@ +--- +'@e2b/code-interpreter': minor +'@e2b/code-interpreter-python': minor +'@e2b/desktop': minor +'@e2b/desktop-python': minor +e2b: patch +'@e2b/python-sdk': patch +--- + +Add an `E2B` client 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 the Code Interpreter Python SDK) 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. + +Binding the configuration to a class now lives on the resource classes themselves (internally), so the core and the downstream clients share one implementation instead of each package generating bound subclasses on its own. 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..ee55ff95de --- /dev/null +++ b/packages/code-interpreter-js/src/client.ts @@ -0,0 +1,73 @@ +import { ConnectionOpts, 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) { + this.Sandbox = Sandbox.withOpts(opts) + this.Volume = Volume.withOpts(opts) + this.Template = Template.withOpts(opts) + this.Secret = Secret.withOpts(opts) + } +} diff --git a/packages/code-interpreter-js/src/index.ts b/packages/code-interpreter-js/src/index.ts index d459812117..e288e77da4 100644 --- a/packages/code-interpreter-js/src/index.ts +++ b/packages/code-interpreter-js/src/index.ts @@ -1,5 +1,6 @@ export * from 'e2b' +export { E2B, 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..d5bbc4e469 --- /dev/null +++ b/packages/code-interpreter-js/tests/client.test.ts @@ -0,0 +1,227 @@ +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 + 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, + 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('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-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..51e7d0b587 100644 --- a/packages/code-interpreter-python/e2b_code_interpreter/__init__.py +++ b/packages/code-interpreter-python/e2b_code_interpreter/__init__.py @@ -1,4 +1,6 @@ from e2b import * +from e2b import __all__ as _e2b_all +from .client import E2B, E2BClientParams from .code_interpreter_sync import Sandbox from .code_interpreter_async import AsyncSandbox from .models import ( @@ -12,3 +14,23 @@ OutputMessage, RunCodeLanguage, ) + +_own_all = [ + "E2B", + "E2BClientParams", + "Sandbox", + "AsyncSandbox", + "Context", + "Execution", + "ExecutionError", + "Result", + "MIMEType", + "Logs", + "OutputHandler", + "OutputMessage", + "RunCodeLanguage", +] + +# The names re-exported from `e2b`, with the ones this package defines (or +# overrides, e.g. `Sandbox`) taking precedence. +__all__ = [name for name in _e2b_all if name not in _own_all] + _own_all 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..25d73ebbda --- /dev/null +++ b/packages/code-interpreter-python/e2b_code_interpreter/client.py @@ -0,0 +1,75 @@ +from e2b import ( + AsyncSecret, + AsyncTemplate, + AsyncVolume, + Secret, + Template, + Volume, +) +from e2b.connection_config import ApiParams +from typing_extensions import Unpack + +from e2b_code_interpreter.code_interpreter_async import AsyncSandbox +from e2b_code_interpreter.code_interpreter_sync import Sandbox + + +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`.""" + + +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. + """ + self.Sandbox = Sandbox._with_params(**opts) + """Code Interpreter `Sandbox` class bound to this client's connection configuration.""" + + self.AsyncSandbox = AsyncSandbox._with_params(**opts) + """Code Interpreter `AsyncSandbox` class bound to this client's connection configuration.""" + + self.Volume = Volume._with_params(**opts) + """`Volume` class bound to this client's connection configuration.""" + + self.AsyncVolume = AsyncVolume._with_params(**opts) + """`AsyncVolume` class bound to this client's connection configuration.""" + + self.Template = Template._with_params(**opts) + """`Template` class bound to this client's connection configuration.""" + + self.AsyncTemplate = AsyncTemplate._with_params(**opts) + """`AsyncTemplate` class bound to this client's connection configuration.""" + + self.Secret = Secret._with_params(**opts) + """`Secret` class bound to this client's connection configuration.""" + + self.AsyncSecret = AsyncSecret._with_params(**opts) + """`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 a3ae7d0939..7191d266f1 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.46.1,<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/desktop-js/README.md b/packages/desktop-js/README.md index 022fdbfd2b..62006fa827 100644 --- a/packages/desktop-js/README.md +++ b/packages/desktop-js/README.md @@ -59,6 +59,30 @@ console.log('Stream URL:', desktop.stream.getUrl({ authKey })) // await desktop.kill() ``` +### 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: + +```javascript +import { E2B } from '@e2b/desktop' + +const client = new E2B({ apiKey: 'e2b_***', domain: 'e2b.dev' }) + +const desktop = await client.Sandbox.create() +await desktop.stream.start() + +// 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. + ## Features ### Streaming desktop's screen diff --git a/packages/desktop-js/src/client.ts b/packages/desktop-js/src/client.ts new file mode 100644 index 0000000000..8c9503faa6 --- /dev/null +++ b/packages/desktop-js/src/client.ts @@ -0,0 +1,72 @@ +import { ConnectionOpts, 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/desktop' + * + * const client = new E2B({ apiKey: 'e2b_...', domain: 'e2b.dev' }) + * + * const desktop = await client.Sandbox.create() + * await desktop.stream.start() + * ``` + */ +export class E2B { + /** + * Desktop `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) { + this.Sandbox = Sandbox.withOpts(opts) + this.Volume = Volume.withOpts(opts) + this.Template = Template.withOpts(opts) + this.Secret = Secret.withOpts(opts) + } +} diff --git a/packages/desktop-js/src/index.ts b/packages/desktop-js/src/index.ts index 3e072a9244..970abeb01c 100644 --- a/packages/desktop-js/src/index.ts +++ b/packages/desktop-js/src/index.ts @@ -1,3 +1,4 @@ export * from 'e2b' +export { E2B, type E2BClientOpts } from './client' export { Sandbox } from './sandbox' diff --git a/packages/desktop-js/tests/client.test.ts b/packages/desktop-js/tests/client.test.ts new file mode 100644 index 0000000000..4e84da9bc6 --- /dev/null +++ b/packages/desktop-js/tests/client.test.ts @@ -0,0 +1,237 @@ +import { + createServer, + IncomingMessage, + Server, + ServerResponse, +} from 'node:http' +import { AddressInfo } from 'node:net' +import { afterAll, assert, beforeAll, beforeEach, test, vi } 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 + 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, + 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: 'desktop', + 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 + } + } + + // Starting the desktop environment talks to envd inside the sandbox, which + // does not exist for a mocked API. The API calls that carry the bound + // configuration all happen before it. + vi.spyOn( + Sandbox.prototype as unknown as { _start: () => Promise }, + '_start' + ).mockResolvedValue(undefined) + + 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 () => { + vi.restoreAllMocks() + + 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 desktop template is still the default. + assert.equal( + (lastRequest().body as { templateID: string }).templateID, + 'desktop' + ) +}) + +test('client.Sandbox instances keep the desktop 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.screenshot) + assert.isFunction(sandbox.stream.start) + assert.equal(sandbox.display, ':0') +}) + +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('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.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/desktop-python/README.md b/packages/desktop-python/README.md index 3f54361a1c..df7b118208 100644 --- a/packages/desktop-python/README.md +++ b/packages/desktop-python/README.md @@ -59,6 +59,30 @@ print('Stream URL:', desktop.stream.get_url(auth_key=auth_key)) # desktop.kill() ``` +### 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 resource classes it exposes: + +```python +from e2b_desktop import E2B + +client = E2B(api_key="e2b_***", domain="e2b.dev") + +desktop = client.Sandbox.create() +desktop.stream.start() + +# 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. + ## Features ### Streaming desktop's screen diff --git a/packages/desktop-python/e2b_desktop/__init__.py b/packages/desktop-python/e2b_desktop/__init__.py index 163338b5d7..ff8962b645 100644 --- a/packages/desktop-python/e2b_desktop/__init__.py +++ b/packages/desktop-python/e2b_desktop/__init__.py @@ -1,3 +1,15 @@ from e2b import * +from e2b import __all__ as _e2b_all +from .client import E2B, E2BClientParams from .main import Sandbox + +_own_all = [ + "E2B", + "E2BClientParams", + "Sandbox", +] + +# The names re-exported from `e2b`, with the ones this package defines (or +# overrides, e.g. `Sandbox`) taking precedence. +__all__ = [name for name in _e2b_all if name not in _own_all] + _own_all diff --git a/packages/desktop-python/e2b_desktop/client.py b/packages/desktop-python/e2b_desktop/client.py new file mode 100644 index 0000000000..5a94f499e9 --- /dev/null +++ b/packages/desktop-python/e2b_desktop/client.py @@ -0,0 +1,71 @@ +from e2b import ( + AsyncSecret, + AsyncTemplate, + AsyncVolume, + Secret, + Template, + Volume, +) +from e2b.connection_config import ApiParams +from typing_extensions import Unpack + +from e2b_desktop.main import Sandbox + + +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`.""" + + +class E2B: + """ + E2B client with an explicitly bound connection configuration. + + The resource classes exposed by the client (`Sandbox`, `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_desktop import E2B + + client = E2B(api_key="e2b_...", domain="e2b.dev") + + desktop = client.Sandbox.create() + desktop.stream.start() + ``` + """ + + 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. + """ + self.Sandbox = Sandbox._with_params(**opts) + """Desktop `Sandbox` class bound to this client's connection configuration.""" + + self.Volume = Volume._with_params(**opts) + """`Volume` class bound to this client's connection configuration.""" + + self.AsyncVolume = AsyncVolume._with_params(**opts) + """`AsyncVolume` class bound to this client's connection configuration.""" + + self.Template = Template._with_params(**opts) + """`Template` class bound to this client's connection configuration.""" + + self.AsyncTemplate = AsyncTemplate._with_params(**opts) + """`AsyncTemplate` class bound to this client's connection configuration.""" + + self.Secret = Secret._with_params(**opts) + """`Secret` class bound to this client's connection configuration.""" + + self.AsyncSecret = AsyncSecret._with_params(**opts) + """`AsyncSecret` class bound to this client's connection configuration.""" diff --git a/packages/desktop-python/pyproject.toml b/packages/desktop-python/pyproject.toml index 9e1511ae77..9c863ee537 100644 --- a/packages/desktop-python/pyproject.toml +++ b/packages/desktop-python/pyproject.toml @@ -7,7 +7,7 @@ license = "MIT" readme = "README.md" requires-python = ">=3.10" dependencies = [ - "e2b>=2.38.0,<3.0.0", + "e2b>=2.46.1,<3.0.0", "requests>=2.32.3,<3", "pillow>=12.0.0,<13", ] diff --git a/packages/desktop-python/tests/test_client.py b/packages/desktop-python/tests/test_client.py new file mode 100644 index 0000000000..7f89d3433c --- /dev/null +++ b/packages/desktop-python/tests/test_client.py @@ -0,0 +1,230 @@ +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.sandbox_sync.commands.command import Commands + +from e2b_desktop import E2B, 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": "desktop", + "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}, + ) + else: + self._record_and_respond(404, {"code": 404, "message": "not found"}) + + +class _StubHandle: + def disconnect(self): + pass + + +@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) + + # Starting the desktop environment talks to envd inside the sandbox, which + # does not exist for a mocked API. All the API calls carrying the bound + # configuration happen before it. + monkeypatch.setattr(Commands, "run", lambda *args, **kwargs: _StubHandle()) + monkeypatch.setattr(Sandbox, "_wait_and_verify", lambda *args, **kwargs: True) + monkeypatch.setattr(Sandbox, "_start_xfce4", lambda self: None) + + _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_desktop_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 == "desktop" + + sandbox = client.Sandbox.create() + + assert isinstance(sandbox, client.Sandbox) + assert isinstance(sandbox, Sandbox) + assert callable(sandbox.screenshot) + assert callable(sandbox.stream.start) + + +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_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 class. + assert Sandbox._bound_api_params == {} diff --git a/packages/js-sdk/src/client.ts b/packages/js-sdk/src/client.ts index 3ae82dcec5..ad75f39928 100644 --- a/packages/js-sdk/src/client.ts +++ b/packages/js-sdk/src/client.ts @@ -1,8 +1,7 @@ import { ConnectionOpts } from './connectionConfig' import { Sandbox } from './sandbox' import { Secret } from './secret' -import { Template, TemplateBase } from './template' -import { callableTemplate } from './template/callable' +import { Template } from './template' import { Volume } from './volume' /** @@ -68,29 +67,9 @@ export class E2B { * 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 - - this.Sandbox = class extends Sandbox { - protected static override readonly boundOpts = boundOpts - } - - this.Volume = class extends Volume { - protected static override readonly boundOpts = boundOpts - } - - this.Secret = class extends Secret { - protected static override readonly boundOpts = boundOpts - } - - this.Template = callableTemplate( - class extends TemplateBase { - protected static override readonly boundOpts = boundOpts - } - ) + this.Sandbox = Sandbox.withOpts(opts) + this.Volume = Volume.withOpts(opts) + this.Secret = Secret.withOpts(opts) + this.Template = Template.withOpts(opts) } } diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index e1fd10d47e..f70316212e 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -555,6 +555,48 @@ export class ClientFactory { */ protected static readonly boundOpts?: Omit + /** + * Create a copy of this class with the connection options bound to it as the + * defaults for every call made through it, instead of the environment + * variables. Per-call options still take precedence, and options already + * bound to the class are kept unless they are passed again. + * + * This is how an {@link E2B} client builds the resources it exposes — the + * clients of the packages built on top of the SDK use it too, so it is not + * `protected`. + * + * @internal + * @hidden + * @hide + */ + static withOpts(this: T, opts?: Omit): T { + // `this` is the class the method is called on, typed loosely so any + // resource class — whatever its constructor looks like — keeps its own + // type through the call. + const cls = this as unknown as typeof ClientFactory + + // 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 = + ConnectionConfig.mergeOpts(cls.boundOpts, { ...(opts ?? {}) }) ?? {} + delete (boundOpts as ConnectionOpts).signal + + // The header objects are copied too, so mutating the ones the caller + // passed cannot change the options bound here. + if (boundOpts.headers) { + boundOpts.headers = { ...boundOpts.headers } + } + if (boundOpts.apiHeaders) { + boundOpts.apiHeaders = { ...boundOpts.apiHeaders } + } + + return class extends cls { + protected static override readonly boundOpts = boundOpts + } as unknown as T + } + /** * Merge the connection options bound to this class with the per-call options, * with the per-call options taking precedence. diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index 1b712c18ae..a9f56ea77a 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -7,7 +7,7 @@ import { } from '../connectionConfig' import { BuildError, InvalidArgumentError } from '../errors' import { runtime, shellQuote } from '../utils' -import { callableTemplate } from './callable' +import { CallableTemplate, callableTemplate } from './callable' import { assignTags, checkAliasExists, @@ -88,6 +88,23 @@ export class TemplateBase options?.fileIgnorePatterns ?? this.fileIgnorePatterns } + /** + * Same as {@link ClientFactory.withOpts}, except the bound class is kept + * callable as a factory, so both `client.Template()` and the statics + * (`client.Template.build(...)`, …) use the bound options. + * + * @internal + * @hidden + * @hide + */ + static override withOpts( + this: T, + opts?: Omit + ): CallableTemplate { + const bound = ClientFactory.withOpts.call(this, opts) + return callableTemplate(bound as unknown as T & typeof TemplateBase) + } + /** * Convert a template to JSON representation. * diff --git a/packages/js-sdk/tests/client.test.ts b/packages/js-sdk/tests/client.test.ts index bb7f47280c..0732f00ab0 100644 --- a/packages/js-sdk/tests/client.test.ts +++ b/packages/js-sdk/tests/client.test.ts @@ -23,6 +23,7 @@ const DOMAIN_ENV = 'env.test' interface RecordedRequest { url: string apiKey?: string + headers: Headers } const requests: RecordedRequest[] = [] @@ -31,6 +32,7 @@ function record(request: Request) { requests.push({ url: request.url, apiKey: request.headers.get('X-API-KEY') ?? undefined, + headers: request.headers, }) } @@ -207,6 +209,16 @@ test('mutating the options object does not change the bound config', async () => assert.equal(lastRequest().url, `https://api.${DOMAIN_A}/sandboxes`) }) +test('mutating the headers object does not change the bound config', async () => { + const apiHeaders = { 'X-Test': 'a' } + const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiHeaders }) + apiHeaders['X-Test'] = 'b' + + await client.Sandbox.create() + + assert.equal(lastRequest().headers.get('X-Test'), 'a') +}) + test('per-call options explicitly set to undefined keep the client config', async () => { const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A }) @@ -392,3 +404,18 @@ test('the default export is still Sandbox', async () => { assert.equal(lastRequest().url, `https://api.${DOMAIN_ENV}/sandboxes`) assert.equal(lastRequest().apiKey, TEST_API_KEY) }) + +test('binding a bound class again keeps the earlier options', async () => { + const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A }) + + const Rebound = client.Sandbox.withOpts({ domain: DOMAIN_B }) + await Rebound.create() + + assert.equal(lastRequest().url, `https://api.${DOMAIN_B}/sandboxes`) + assert.equal(lastRequest().apiKey, API_KEY_A) + + // The class it was bound from is unchanged. + await client.Sandbox.create() + assert.equal(lastRequest().url, `https://api.${DOMAIN_A}/sandboxes`) + assert.equal(lastRequest().apiKey, API_KEY_A) +}) diff --git a/packages/python-sdk/e2b/client.py b/packages/python-sdk/e2b/client.py index ada3965f0c..3e82652bf2 100644 --- a/packages/python-sdk/e2b/client.py +++ b/packages/python-sdk/e2b/client.py @@ -1,5 +1,3 @@ -from typing import Dict, Type, TypeVar, cast - from typing_extensions import Unpack from e2b.connection_config import ApiParams @@ -11,22 +9,12 @@ from e2b.volume.volume_async import AsyncVolume from e2b.volume.volume_sync import Volume -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. @@ -60,30 +48,26 @@ def __init__(self, **opts: Unpack[E2BClientParams]): :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))) - - self.Sandbox = _bind(Sandbox, api_params) + self.Sandbox = Sandbox._with_params(**opts) """`Sandbox` class bound to this client's connection configuration.""" - self.AsyncSandbox = _bind(AsyncSandbox, api_params) + self.AsyncSandbox = AsyncSandbox._with_params(**opts) """`AsyncSandbox` class bound to this client's connection configuration.""" - self.Volume = _bind(Volume, api_params) + self.Volume = Volume._with_params(**opts) """`Volume` class bound to this client's connection configuration.""" - self.AsyncVolume = _bind(AsyncVolume, api_params) + self.AsyncVolume = AsyncVolume._with_params(**opts) """`AsyncVolume` class bound to this client's connection configuration.""" - self.Template = _bind(Template, api_params) + self.Template = Template._with_params(**opts) """`Template` class bound to this client's connection configuration.""" - self.AsyncTemplate = _bind(AsyncTemplate, api_params) + self.AsyncTemplate = AsyncTemplate._with_params(**opts) """`AsyncTemplate` class bound to this client's connection configuration.""" - self.Secret = _bind(Secret, api_params) + self.Secret = Secret._with_params(**opts) """`Secret` class bound to this client's connection configuration.""" - self.AsyncSecret = _bind(AsyncSecret, api_params) + self.AsyncSecret = AsyncSecret._with_params(**opts) """`AsyncSecret` class bound to this client's connection configuration.""" diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index 71bef0991d..e911c618c6 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -1,10 +1,10 @@ import logging import os -from typing import cast, Mapping, Optional, Dict, TypedDict, Union +from typing import cast, Mapping, Optional, Dict, Type, TypedDict, Union import httpx -from typing_extensions import Unpack +from typing_extensions import Self, Unpack from e2b.api.metadata import package_version from e2b.sandbox_domains import is_supported_sandbox_domain @@ -113,21 +113,51 @@ class ClientFactory: """ Base class for the resource classes (`Sandbox`, `Volume`, `Template`, `Secret`) whose classmethods build a :class:`ConnectionConfig` from per-call - params. An :class:`e2b.E2B` client exposes subclasses of these with its own - params bound, and every classmethod resolves them through - :meth:`_resolve_api_params`. + params. :meth:`_with_params` binds params to a copy of the class — that is + what an :class:`e2b.E2B` client exposes — and every classmethod resolves + them through :meth:`_resolve_api_params`. :meta private: """ _bound_api_params: ApiParams = {} - """API params bound to this class by an :class:`e2b.E2B` client. + """API params bound to this class by :meth:`_with_params`. Empty on the base classes, so the env-configured default path is unchanged. :meta private: """ + @classmethod + def _with_params(cls, **api_params: Unpack[ApiParams]) -> Type[Self]: + """ + Create a copy of this class with the API params bound to it as the + defaults for every call made through it, instead of the environment + variables. Per-call params still take precedence, and params already + bound to the class are kept unless they are passed again. + + This is how an :class:`e2b.E2B` client builds the resources it exposes; + the clients of the packages built on top of the SDK use it too. + + :meta private: + """ + bound = merge_api_params(cls._bound_api_params, api_params) + + # The header dicts are copied too, so mutating the ones the caller + # passed cannot change the params bound here. + headers = bound.get("headers") + if headers is not None: + bound["headers"] = dict(headers) + + api_headers = bound.get("api_headers") + if api_headers is not None: + bound["api_headers"] = dict(api_headers) + + return cast( + Type[Self], + type(cls.__name__, (cls,), {"_bound_api_params": bound}), + ) + @classmethod def _resolve_api_params(cls, **opts: Unpack[ApiParams]) -> ApiParams: """ diff --git a/packages/python-sdk/tests/test_client.py b/packages/python-sdk/tests/test_client.py index 21ad9dbc15..1933e88430 100644 --- a/packages/python-sdk/tests/test_client.py +++ b/packages/python-sdk/tests/test_client.py @@ -319,3 +319,26 @@ def test_client_params_are_copied(api_server): assert api_keys() == [API_KEY_A] assert sandbox.connection_config.api_key == API_KEY_A + + +def test_client_header_params_are_copied(api_server): + api_headers = {"X-Test": "a"} + client = E2B(api_key=API_KEY_A, api_url=api_server, api_headers=api_headers) + api_headers["X-Test"] = "b" + + client.Sandbox.create() + + assert requests()[-1][2]["x-test"] == "a" + + +def test_binding_a_bound_class_again_keeps_the_earlier_params(api_server): + client = E2B(api_key=API_KEY_A, domain=DOMAIN_A, api_url=api_server) + + rebound = client.Sandbox._with_params(domain=DOMAIN_B) + sandbox = rebound.create() + + assert api_keys() == [API_KEY_A] + assert sandbox.connection_config.api_key == API_KEY_A + assert sandbox.connection_config.domain == DOMAIN_B + # The class it was bound from is unchanged. + assert client.Sandbox._bound_api_params["domain"] == DOMAIN_A