diff --git a/.changeset/lucky-moons-refactor.md b/.changeset/lucky-moons-refactor.md new file mode 100644 index 0000000000..10cd0b52d2 --- /dev/null +++ b/.changeset/lucky-moons-refactor.md @@ -0,0 +1,6 @@ +--- +'e2b': patch +'@e2b/python-sdk': patch +--- + +Rework the multi-client binding: the resource classes (`Sandbox`, `Volume`, `Template`, `Secret`, and the async Python variants) gain a public `withOptions(opts)` (JS) / `with_params(**params)` (Python) static/classmethod that returns a copy of the class with the connection config bound, merging with any config already bound to it. The `E2B` client builds its resources through these diff --git a/packages/js-sdk/src/client.ts b/packages/js-sdk/src/client.ts index 3ae82dcec5..84e5653f55 100644 --- a/packages/js-sdk/src/client.ts +++ b/packages/js-sdk/src/client.ts @@ -1,17 +1,10 @@ -import { ConnectionOpts } from './connectionConfig' +import { E2BClientOpts } 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' -/** - * 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 +export type { E2BClientOpts } from './connectionConfig' /** * E2B client with an explicitly bound connection configuration. @@ -68,29 +61,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.withOptions(opts) + this.Volume = Volume.withOptions(opts) + this.Secret = Secret.withOptions(opts) + this.Template = Template.withOptions(opts) } } diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index e1fd10d47e..26f9336f33 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -98,6 +98,14 @@ export interface ConnectionOpts { */ export type ConnectionConfigOpts = ConnectionOpts +/** + * 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 + /** * Build an `AbortSignal` that combines an optional request-timeout signal * (via `AbortSignal.timeout`) with an optional user-provided signal. @@ -553,7 +561,7 @@ export class ClientFactory { * @hidden * @hide */ - protected static readonly boundOpts?: Omit + protected static readonly boundOpts?: E2BClientOpts /** * Merge the connection options bound to this class with the per-call options, @@ -568,6 +576,75 @@ export class ClientFactory { ): T | undefined { return ConnectionConfig.mergeOpts(this.boundOpts, opts) } + + /** + * Create a copy of this class with the connection options bound to it, used + * as the defaults for every call instead of the environment variables. + * Per-call options still take precedence over the bound options. + * This class is not modified. + * + * Options already bound to this class are kept and merged with `opts`, with + * `opts` taking precedence. + * + * @param opts connection options to bind. + * + * @returns a subclass of this class with the merged options bound. + * + * @example + * ```ts + * const MySandbox = Sandbox.withOptions({ apiKey: 'e2b_...' }) + * const sandbox = await MySandbox.create() + * ``` + */ + static withOptions( + this: T, + opts?: E2BClientOpts + ): T { + return bindClientOpts(this, opts) + } +} + +/** + * Return a subclass of `cls` with `opts` bound to it, used as the defaults + * for every call instead of the environment variables. + * Per-call options still take precedence over the bound options. + * + * Options already bound to `cls` are kept and merged with `opts`, with + * `opts` taking precedence. + * + * Internal helper for building multi-clients (like {@link E2B}); + * not part of the public API. + * + * @internal + * @hidden + * @hide + */ +// The static sides of the subclasses are not assignable to +// `typeof ClientFactory` (their constructors differ), and TS has no way to +// type "subclass of `cls`", so the constraint is structural and the subclass +// expression cannot be typed as `T` without the cast. +export function bindClientOpts( + cls: T, + opts?: E2BClientOpts +): T { + const base = cls as unknown as typeof ClientFactory & { + readonly boundOpts?: E2BClientOpts + } + + // Options are shallow-copied so later top-level 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 = { + ...(base.boundOpts ?? {}), + ...(opts ?? {}), + } + delete (boundOpts as ConnectionOpts).signal + + return class extends base { + static override readonly boundOpts = boundOpts + } as unknown as T } /** diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index 1b712c18ae..6960eec821 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -1,9 +1,11 @@ import type { PathLike } from 'node:fs' import { ApiClient } from '../api' import { + bindClientOpts, ClientFactory, ConnectionConfig, ConnectionOpts, + E2BClientOpts, } from '../connectionConfig' import { BuildError, InvalidArgumentError } from '../errors' import { runtime, shellQuote } from '../utils' @@ -63,6 +65,34 @@ export class TemplateBase extends ClientFactory implements TemplateFromImage, TemplateBuilder, TemplateFinal { + /** + * Create a copy of this template with the connection options bound to it, + * used as the defaults for every call instead of the environment variables. + * Per-call options still take precedence over the bound options. + * This template is not modified. + * + * Options already bound to this template are kept and merged with `opts`, + * with `opts` taking precedence. + * + * @param opts connection options to bind. + * + * @returns a callable template with the merged options bound. + * + * @example + * ```ts + * const MyTemplate = Template.withOptions({ apiKey: 'e2b_...' }) + * await MyTemplate.build(MyTemplate().fromPythonImage('3'), 'my-env') + * ``` + */ + static override withOptions( + this: T, + opts?: E2BClientOpts + ): T { + return callableTemplate( + bindClientOpts(this as unknown as typeof TemplateBase, opts) + ) as unknown as T + } + private defaultBaseImage: string = 'e2bdev/base' private baseImage: string | undefined = this.defaultBaseImage private baseTemplate: string | undefined = undefined diff --git a/packages/js-sdk/tests/client.test.ts b/packages/js-sdk/tests/client.test.ts index bb7f47280c..9670c30a81 100644 --- a/packages/js-sdk/tests/client.test.ts +++ b/packages/js-sdk/tests/client.test.ts @@ -197,6 +197,43 @@ test('two clients with different configs stay isolated', async () => { ) }) +test('Sandbox.withOptions returns a bound class with merged options', async () => { + const BoundSandbox = Sandbox.withOptions({ + apiKey: API_KEY_A, + domain: DOMAIN_A, + }) + const ReboundSandbox = BoundSandbox.withOptions({ domain: DOMAIN_B }) + + await ReboundSandbox.create() + await BoundSandbox.create() + await Sandbox.create() + + assert.deepEqual( + requests.map((r) => [r.url, r.apiKey]), + [ + [`https://api.${DOMAIN_B}/sandboxes`, API_KEY_A], + [`https://api.${DOMAIN_A}/sandboxes`, API_KEY_A], + [`https://api.${DOMAIN_ENV}/sandboxes`, TEST_API_KEY], + ] + ) +}) + +test('Template.withOptions returns a callable template with the options bound', async () => { + const BoundTemplate = Template.withOptions({ + apiKey: API_KEY_A, + domain: DOMAIN_A, + }) + + expect(BoundTemplate().fromPythonImage('3')).toBeDefined() + await BoundTemplate.exists('tmpl') + + assert.equal( + lastRequest().url, + `https://api.${DOMAIN_A}/templates/aliases/tmpl` + ) + assert.equal(lastRequest().apiKey, API_KEY_A) +}) + test('mutating the options object does not change the bound config', async () => { const opts = { apiKey: API_KEY_A, domain: DOMAIN_A } const client = new E2B(opts) diff --git a/packages/js-sdk/tests/template/boundOpts.test.ts b/packages/js-sdk/tests/template/boundOpts.test.ts index 855fb2d8be..606a5ef590 100644 --- a/packages/js-sdk/tests/template/boundOpts.test.ts +++ b/packages/js-sdk/tests/template/boundOpts.test.ts @@ -4,6 +4,7 @@ import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { BuildOptions, Template, TemplateBase } from '../../src' +import { bindClientOpts } from '../../src/connectionConfig' import { apiUrl, TEST_API_KEY } from '../setup' const BOUND_API_KEY = `e2b_${'1'.repeat(40)}` @@ -163,3 +164,27 @@ test('build options carry the bound options into the build', async () => { requestTimeoutMs: 42, }) }) + +test('bindClientOpts merges with already-bound options', () => { + class ProbeTemplate extends TemplateBase { + static probeBuildOpts(options?: BuildOptions) { + return this.resolveOpts(options) + } + } + + const bound = bindClientOpts(ProbeTemplate, { + apiKey: BOUND_API_KEY, + requestTimeoutMs: 1234, + }) + const rebound = bindClientOpts(bound, { requestTimeoutMs: 42 }) + + expect(rebound.probeBuildOpts()).toEqual({ + apiKey: BOUND_API_KEY, + requestTimeoutMs: 42, + }) + // The original class keeps its own bound options. + expect(bound.probeBuildOpts()).toEqual({ + apiKey: BOUND_API_KEY, + requestTimeoutMs: 1234, + }) +}) diff --git a/packages/python-sdk/e2b/client.py b/packages/python-sdk/e2b/client.py index ada3965f0c..2d1474a1af 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..34e4db6ef6 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -1,7 +1,7 @@ import logging import os -from typing import cast, Mapping, Optional, Dict, TypedDict, Union +from typing import cast, Mapping, Optional, Dict, Type, TypedDict, TypeVar, Union import httpx from typing_extensions import Unpack @@ -109,6 +109,9 @@ def merge_api_params( return cast(ApiParams, merged) +_ClientT = TypeVar("_ClientT", bound="ClientFactory") + + class ClientFactory: """ Base class for the resource classes (`Sandbox`, `Volume`, `Template`, @@ -137,6 +140,54 @@ def _resolve_api_params(cls, **opts: Unpack[ApiParams]) -> ApiParams: """ return merge_api_params(cls._bound_api_params, opts) + @classmethod + def with_params(cls: Type[_ClientT], **params: Unpack[ApiParams]) -> Type[_ClientT]: + """ + Create a copy of this class with ``params`` bound to it, used as the + defaults for every call instead of the environment variables. + Per-call params still take precedence over the bound params. + This class is not modified. + + Params already bound to this class are kept and merged with ``params``, + with ``params`` taking precedence. + + :param params: API params to bind. + + :return: A subclass of this class with the merged params bound. + + Example: + ```python + MySandbox = Sandbox.with_params(api_key="e2b_...") + sandbox = MySandbox.create() + ``` + """ + return bind_client_params(cls, **params) + + +def bind_client_params( + cls: Type[_ClientT], **params: Unpack[ApiParams] +) -> Type[_ClientT]: + """ + Return a subclass of ``cls`` with ``params`` bound to it, used as the + defaults for every call instead of the environment variables. + Per-call params still take precedence over the bound params. + + Params already bound to ``cls`` are kept and merged with ``params``, with + ``params`` taking precedence. The params are shallow-copied into a fresh + dict, so later top-level mutations of the caller's dicts cannot change the + bound configuration. + + Internal helper for building multi-clients (like :class:`e2b.E2B`); + not part of the public API. + + :meta private: + """ + bound = cast(ApiParams, {**cls._bound_api_params, **params}) + return cast( + Type[_ClientT], + type(cls.__name__, (cls,), {"_bound_api_params": bound}), + ) + class ConnectionConfig: """ diff --git a/packages/python-sdk/tests/test_client.py b/packages/python-sdk/tests/test_client.py index 21ad9dbc15..50561d29d3 100644 --- a/packages/python-sdk/tests/test_client.py +++ b/packages/python-sdk/tests/test_client.py @@ -17,6 +17,7 @@ Template, Volume, ) +from e2b.connection_config import bind_client_params API_KEY_A = "e2b_" + "a" * 40 API_KEY_B = "e2b_" + "b" * 40 @@ -319,3 +320,28 @@ 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_resource_with_params_returns_a_bound_class_with_merged_params(api_server): + BoundSandbox = Sandbox.with_params( + api_key=API_KEY_A, domain=DOMAIN_A, api_url=api_server + ) + ReboundSandbox = BoundSandbox.with_params(domain=DOMAIN_B) + + sandbox = ReboundSandbox.create() + + assert api_keys() == [API_KEY_A] + assert sandbox.connection_config.api_key == API_KEY_A + assert sandbox.connection_config.domain == DOMAIN_B + # The original classes keep their own bound params. + assert BoundSandbox._bound_api_params["domain"] == DOMAIN_A + assert Sandbox._bound_api_params == {} + + +def test_bind_client_params_merges_with_already_bound_params(): + bound = bind_client_params(Sandbox, api_key=API_KEY_A, domain=DOMAIN_A) + rebound = bind_client_params(bound, domain=DOMAIN_B) + + assert rebound._bound_api_params == {"api_key": API_KEY_A, "domain": DOMAIN_B} + # The original class keeps its own bound params. + assert bound._bound_api_params == {"api_key": API_KEY_A, "domain": DOMAIN_A}