diff --git a/.changeset/snapshot-name.md b/.changeset/snapshot-name.md new file mode 100644 index 0000000000..19e9760512 --- /dev/null +++ b/.changeset/snapshot-name.md @@ -0,0 +1,6 @@ +--- +'@e2b/python-sdk': patch +'e2b': patch +--- + +add optional name parameter to createSnapshot and return snapshot names diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index e67092a01c..428affe925 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -63,6 +63,7 @@ export type { SnapshotInfo, SnapshotListOpts, SnapshotPaginator, + CreateSnapshotOpts, } from './sandbox/sandboxApi' export type { McpServer } from './sandbox/mcp' diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index c223c6b6f4..27b8cc9430 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -20,10 +20,10 @@ import { SandboxListOpts, SandboxPaginator, SandboxBetaCreateOpts, - SandboxApiOpts, SnapshotListOpts, SnapshotInfo, SnapshotPaginator, + CreateSnapshotOpts, } from './sandboxApi' import { getSignature } from './signature' import { compareVersions } from 'compare-versions' @@ -610,7 +610,7 @@ export class Sandbox extends SandboxApi { * * Use the returned `snapshotId` with `Sandbox.create(snapshotId)` to create a new sandbox from the snapshot. * - * @param opts connection options. + * @param opts snapshot creation options including optional name and connection options. * * @returns snapshot information including the snapshot ID. * @@ -620,17 +620,17 @@ export class Sandbox extends SandboxApi { * await sandbox.files.write('/app/state.json', '{"step": 1}') * * // Create a snapshot - * const snapshot = await sandbox.createSnapshot() + * const snapshot = await sandbox.createSnapshot({ name: 'my-snapshot' }) * * // Create a new sandbox from the snapshot * const newSandbox = await Sandbox.create(snapshot.snapshotId) * ``` */ - async createSnapshot(opts?: SandboxApiOpts): Promise { - return await SandboxApi.createSnapshot( - this.sandboxId, - this.resolveApiOpts(opts) - ) + async createSnapshot(opts?: CreateSnapshotOpts): Promise { + return await SandboxApi.createSnapshot(this.sandboxId, { + ...this.resolveApiOpts(opts), + name: opts?.name, + }) } /** diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 6fc03addb7..404eb81c73 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -283,6 +283,23 @@ export interface SnapshotInfo { * Can be used with Sandbox.create() to create a new sandbox from this snapshot. */ snapshotId: string + + /** + * Full names of the snapshot template including team namespace and tag (e.g. team-slug/my-snapshot:v2). + */ + names: string[] +} + +/** + * Options for creating a snapshot. + */ +export interface CreateSnapshotOpts extends SandboxApiOpts { + /** + * Optional name for the snapshot template. + * If a snapshot template with this name already exists, a new build will be assigned + * to the existing template instead of creating a new one. + */ + name?: string } /** @@ -688,13 +705,13 @@ export class SandboxApi { * The snapshot is a persistent image that survives sandbox deletion. * * @param sandboxId sandbox ID to create snapshot from. - * @param opts connection options. + * @param opts snapshot creation options including optional name and connection options. * * @returns snapshot information including the snapshot name that can be used with Sandbox.create(). */ static async createSnapshot( sandboxId: string, - opts?: SandboxApiOpts + opts?: CreateSnapshotOpts ): Promise { const config = new ConnectionConfig(opts) const client = new ApiClient(config) @@ -705,7 +722,7 @@ export class SandboxApi { sandboxID: sandboxId, }, }, - body: {}, + body: opts?.name ? { name: opts.name } : {}, signal: config.getSignal(opts?.requestTimeoutMs), }) @@ -720,6 +737,7 @@ export class SandboxApi { return { snapshotId: res.data!.snapshotID, + names: res.data!.names ?? [], } } @@ -1045,6 +1063,7 @@ export class SnapshotPaginator extends BasePaginator { return (res.data ?? []).map( (snapshot: components['schemas']['SnapshotInfo']) => ({ snapshotId: snapshot.snapshotID, + names: snapshot.names ?? [], }) ) } diff --git a/packages/js-sdk/tests/sandbox/snapshot-api.test.ts b/packages/js-sdk/tests/sandbox/snapshot-api.test.ts index 69eabf119e..e22387a4af 100644 --- a/packages/js-sdk/tests/sandbox/snapshot-api.test.ts +++ b/packages/js-sdk/tests/sandbox/snapshot-api.test.ts @@ -141,6 +141,24 @@ sandboxTest.skipIf(isDebug)( } ) +sandboxTest.skipIf(isDebug)( + 'create a named snapshot', + async ({ sandbox, sandboxTestId }) => { + const snapshotName = `snap-${sandboxTestId}` + + const snapshot = await sandbox.createSnapshot({ name: snapshotName }) + + try { + assert.isString(snapshot.snapshotId) + assert.isArray(snapshot.names) + assert.isTrue(snapshot.names.length > 0) + assert.isTrue(snapshot.names.some((n) => n.includes(snapshotName))) + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } + } +) + sandboxTest.skipIf(isDebug)('delete snapshot', async ({ sandbox }) => { const snapshot = await sandbox.createSnapshot() diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 96e4d46271..9c8c0ee34f 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -296,6 +296,8 @@ class SnapshotInfo: snapshot_id: str """Snapshot identifier — template ID with tag, or namespaced name with tag (e.g. my-snapshot:latest). Can be used with Sandbox.create() to create a new sandbox from this snapshot.""" + names: List[str] = field(default_factory=list) + """Full names of the snapshot template including team namespace and tag (e.g. team-slug/my-snapshot:v2).""" class PaginatorBase: diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 3dd7044ea7..83e691f71a 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -695,6 +695,7 @@ async def beta_pause( @overload async def create_snapshot( self, + name: Optional[str] = None, **opts: Unpack[ApiParams], ) -> SnapshotInfo: """ @@ -706,7 +707,9 @@ async def create_snapshot( Use the returned `snapshot_id` with `AsyncSandbox.create(snapshot_id)` to create a new sandbox from the snapshot. - :return: Snapshot information including the snapshot ID + :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + + :return: Snapshot information including the snapshot ID and names """ ... @@ -714,6 +717,7 @@ async def create_snapshot( @staticmethod async def create_snapshot( sandbox_id: str, + name: Optional[str] = None, **opts: Unpack[ApiParams], ) -> SnapshotInfo: """ @@ -722,14 +726,16 @@ async def create_snapshot( The sandbox will be paused while the snapshot is being created. :param sandbox_id: Sandbox ID + :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. - :return: Snapshot information including the snapshot ID + :return: Snapshot information including the snapshot ID and names """ ... @class_method_variant("_cls_create_snapshot") async def create_snapshot( self, + name: Optional[str] = None, **opts: Unpack[ApiParams], ) -> SnapshotInfo: """ @@ -741,10 +747,13 @@ async def create_snapshot( Use the returned `snapshot_id` with `AsyncSandbox.create(snapshot_id)` to create a new sandbox from the snapshot. - :return: Snapshot information including the snapshot ID + :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + + :return: Snapshot information including the snapshot ID and names """ return await SandboxApi._cls_create_snapshot( sandbox_id=self.sandbox_id, + name=name, **self.connection_config.get_api_params(**opts), ) diff --git a/packages/python-sdk/e2b/sandbox_async/paginator.py b/packages/python-sdk/e2b/sandbox_async/paginator.py index de6d31fb63..2533b5e298 100644 --- a/packages/python-sdk/e2b/sandbox_async/paginator.py +++ b/packages/python-sdk/e2b/sandbox_async/paginator.py @@ -121,5 +121,9 @@ async def next_items(self) -> List[SnapshotInfo]: raise SandboxException(f"{res.parsed.message}: Request failed") return [ - SnapshotInfo(snapshot_id=snapshot.snapshot_id) for snapshot in res.parsed + SnapshotInfo( + snapshot_id=snapshot.snapshot_id, + names=list(snapshot.names) if snapshot.names else [], + ) + for snapshot in res.parsed ] diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 8946b038fa..2bd1f73e57 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -297,6 +297,7 @@ async def _cls_get_metrics( async def _cls_create_snapshot( cls, sandbox_id: str, + name: Optional[str] = None, **opts: Unpack[ApiParams], ) -> SnapshotInfo: config = ConnectionConfig(**opts) @@ -305,7 +306,7 @@ async def _cls_create_snapshot( res = await post_sandboxes_sandbox_id_snapshots.asyncio_detailed( sandbox_id, client=api_client, - body=PostSandboxesSandboxIDSnapshotsBody(), + body=PostSandboxesSandboxIDSnapshotsBody(name=name if name else UNSET), ) if res.status_code == 404: @@ -320,7 +321,10 @@ async def _cls_create_snapshot( if isinstance(res.parsed, Error): raise SandboxException(f"{res.parsed.message}: Request failed") - return SnapshotInfo(snapshot_id=res.parsed.snapshot_id) + return SnapshotInfo( + snapshot_id=res.parsed.snapshot_id, + names=list(res.parsed.names) if res.parsed.names else [], + ) @classmethod async def _cls_delete_snapshot( diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 43f3a858c5..bb818b9e21 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -692,6 +692,7 @@ def beta_pause( @overload def create_snapshot( self, + name: Optional[str] = None, **opts: Unpack[ApiParams], ) -> SnapshotInfo: """ @@ -703,7 +704,9 @@ def create_snapshot( Use the returned `snapshot_id` with `Sandbox.create(snapshot_id)` to create a new sandbox from the snapshot. - :return: Snapshot information including the snapshot ID + :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + + :return: Snapshot information including the snapshot ID and names """ ... @@ -711,6 +714,7 @@ def create_snapshot( @staticmethod def create_snapshot( sandbox_id: str, + name: Optional[str] = None, **opts: Unpack[ApiParams], ) -> SnapshotInfo: """ @@ -719,14 +723,16 @@ def create_snapshot( The sandbox will be paused while the snapshot is being created. :param sandbox_id: Sandbox ID + :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. - :return: Snapshot information including the snapshot ID + :return: Snapshot information including the snapshot ID and names """ ... @class_method_variant("_cls_create_snapshot") def create_snapshot( self, + name: Optional[str] = None, **opts: Unpack[ApiParams], ) -> SnapshotInfo: """ @@ -738,10 +744,13 @@ def create_snapshot( Use the returned `snapshot_id` with `Sandbox.create(snapshot_id)` to create a new sandbox from the snapshot. - :return: Snapshot information including the snapshot ID + :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + + :return: Snapshot information including the snapshot ID and names """ return SandboxApi._cls_create_snapshot( sandbox_id=self.sandbox_id, + name=name, **self.connection_config.get_api_params(**opts), ) diff --git a/packages/python-sdk/e2b/sandbox_sync/paginator.py b/packages/python-sdk/e2b/sandbox_sync/paginator.py index 0a5649052b..4f0fb454c8 100644 --- a/packages/python-sdk/e2b/sandbox_sync/paginator.py +++ b/packages/python-sdk/e2b/sandbox_sync/paginator.py @@ -121,5 +121,9 @@ def next_items(self) -> List[SnapshotInfo]: raise SandboxException(f"{res.parsed.message}: Request failed") return [ - SnapshotInfo(snapshot_id=snapshot.snapshot_id) for snapshot in res.parsed + SnapshotInfo( + snapshot_id=snapshot.snapshot_id, + names=list(snapshot.names) if snapshot.names else [], + ) + for snapshot in res.parsed ] diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 4cad1247dc..648c1b414b 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -324,6 +324,7 @@ def _cls_connect( def _cls_create_snapshot( cls, sandbox_id: str, + name: Optional[str] = None, **opts: Unpack[ApiParams], ) -> SnapshotInfo: config = ConnectionConfig(**opts) @@ -332,7 +333,7 @@ def _cls_create_snapshot( res = post_sandboxes_sandbox_id_snapshots.sync_detailed( sandbox_id, client=api_client, - body=PostSandboxesSandboxIDSnapshotsBody(), + body=PostSandboxesSandboxIDSnapshotsBody(name=name if name else UNSET), ) if res.status_code == 404: @@ -347,7 +348,10 @@ def _cls_create_snapshot( if isinstance(res.parsed, Error): raise SandboxException(f"{res.parsed.message}: Request failed") - return SnapshotInfo(snapshot_id=res.parsed.snapshot_id) + return SnapshotInfo( + snapshot_id=res.parsed.snapshot_id, + names=list(res.parsed.names) if res.parsed.names else [], + ) @classmethod def _cls_delete_snapshot( diff --git a/packages/python-sdk/tests/async/sandbox_async/test_snapshot_api.py b/packages/python-sdk/tests/async/sandbox_async/test_snapshot_api.py index ff4c9b33dd..e9673b069f 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_snapshot_api.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_snapshot_api.py @@ -96,6 +96,21 @@ async def test_list_snapshots_for_sandbox(async_sandbox: AsyncSandbox): await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) +@pytest.mark.skip_debug() +async def test_create_named_snapshot(async_sandbox: AsyncSandbox, sandbox_test_id: str): + snapshot_name = f"snap-{sandbox_test_id}" + + snapshot = await async_sandbox.create_snapshot(name=snapshot_name) + + try: + assert snapshot.snapshot_id + assert isinstance(snapshot.names, list) + assert len(snapshot.names) > 0 + assert any(snapshot_name in n for n in snapshot.names) + finally: + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + + @pytest.mark.skip_debug() async def test_delete_snapshot(async_sandbox: AsyncSandbox): snapshot = await async_sandbox.create_snapshot() diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_api.py b/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_api.py index 97b7b23672..1eef251d9d 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_api.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_api.py @@ -94,6 +94,21 @@ def test_list_snapshots_for_sandbox(sandbox: Sandbox): Sandbox.delete_snapshot(snapshot.snapshot_id) +@pytest.mark.skip_debug() +def test_create_named_snapshot(sandbox: Sandbox, sandbox_test_id: str): + snapshot_name = f"snap-{sandbox_test_id}" + + snapshot = sandbox.create_snapshot(name=snapshot_name) + + try: + assert snapshot.snapshot_id + assert isinstance(snapshot.names, list) + assert len(snapshot.names) > 0 + assert any(snapshot_name in n for n in snapshot.names) + finally: + Sandbox.delete_snapshot(snapshot.snapshot_id) + + @pytest.mark.skip_debug() def test_delete_snapshot(sandbox: Sandbox): snapshot = sandbox.create_snapshot()