diff --git a/.changeset/get-template-tags.md b/.changeset/get-template-tags.md new file mode 100644 index 0000000000..cec61f7812 --- /dev/null +++ b/.changeset/get-template-tags.md @@ -0,0 +1,6 @@ +--- +'e2b': patch +'@e2b/python-sdk': patch +--- + +Add `getTags`/`get_tags` method to list all tags for a template diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index 70c98a1919..fdc43874af 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -1023,6 +1023,47 @@ export interface paths { patch?: never; trace?: never; }; + "/templates/{templateID}/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List all tags for a template */ + get: { + parameters: { + query?: never; + header?: never; + path: { + templateID: components["parameters"]["templateID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully listed template tags */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateTag"][]; + }; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/templates/aliases/{alias}": { parameters: { query?: never; @@ -2261,6 +2302,20 @@ export interface components { /** @description Type of the step */ type: string; }; + TemplateTag: { + /** + * Format: uuid + * @description Identifier of the build associated with this tag + */ + buildID: string; + /** + * Format: date-time + * @description When this tag was assigned + */ + createdAt: string; + /** @description Name of the tag */ + tag: string; + }; TemplateUpdateRequest: { /** @description Whether the template is public or only accessible by the team */ public?: boolean; diff --git a/packages/js-sdk/src/template/buildApi.ts b/packages/js-sdk/src/template/buildApi.ts index d56d953c67..d699efe03c 100644 --- a/packages/js-sdk/src/template/buildApi.ts +++ b/packages/js-sdk/src/template/buildApi.ts @@ -1,4 +1,4 @@ -import { ApiClient, handleApiError, paths } from '../api' +import { ApiClient, handleApiError, paths, components } from '../api' import { stripAnsi } from '../utils' import { BuildError, FileUploadError, TemplateError } from '../errors' import { LogEntry } from './logger' @@ -7,6 +7,7 @@ import { BuildStatusReason, TemplateBuildStatus, TemplateBuildStatusResponse, + TemplateTag, TemplateTagInfo, } from './types' @@ -358,3 +359,31 @@ export async function removeTags( throw error } } + +export async function getTemplateTags( + client: ApiClient, + { templateID }: { templateID: string } +): Promise { + const res = await client.api.GET('/templates/{templateID}/tags', { + params: { + path: { + templateID, + }, + }, + }) + + const error = handleApiError(res, TemplateError) + if (error) { + throw error + } + + if (!res.data) { + throw new TemplateError('Failed to get template tags') + } + + return res.data.map((item: components['schemas']['TemplateTag']) => ({ + tag: item.tag, + buildId: item.buildID, + createdAt: new Date(item.createdAt), + })) +} diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index 78d9035907..e7bc1e6742 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -6,6 +6,7 @@ import { runtime } from '../utils' import { assignTags, checkAliasExists, + getTemplateTags, removeTags, getBuildStatus, getFileUploadLink, @@ -34,6 +35,7 @@ import { TemplateFinal, TemplateFromImage, TemplateOptions, + TemplateTag, TemplateTagInfo, } from './types' import { @@ -371,6 +373,30 @@ export class TemplateBase return removeTags(client, { name, tags: normalizedTags }) } + /** + * Get all tags for a template. + * + * @param templateId Template ID or name + * @param options Authentication options + * @returns Array of tag details including tag name, buildId, and creation date + * + * @example + * ```ts + * const tags = await Template.getTags('my-template') + * for (const tag of tags) { + * console.log(`Tag: ${tag.tag}, Build: ${tag.buildId}, Created: ${tag.createdAt}`) + * } + * ``` + */ + static async getTags( + templateId: string, + options?: ConnectionOpts + ): Promise { + const config = new ConnectionConfig(options) + const client = new ApiClient(config) + return getTemplateTags(client, { templateID: templateId }) + } + fromDebianImage(variant: string = 'stable'): TemplateBuilder { return this.fromImage(`debian:${variant}`) } @@ -1265,6 +1291,7 @@ Template.exists = TemplateBase.exists Template.aliasExists = TemplateBase.aliasExists Template.assignTags = TemplateBase.assignTags Template.removeTags = TemplateBase.removeTags +Template.getTags = TemplateBase.getTags Template.toJSON = TemplateBase.toJSON Template.toDockerfile = TemplateBase.toDockerfile @@ -1279,5 +1306,6 @@ export type { TemplateBuildStatus, TemplateBuildStatusResponse, TemplateClass, + TemplateTag, TemplateTagInfo, } from './types' diff --git a/packages/js-sdk/src/template/types.ts b/packages/js-sdk/src/template/types.ts index 23999de7c0..4b032b6ba5 100644 --- a/packages/js-sdk/src/template/types.ts +++ b/packages/js-sdk/src/template/types.ts @@ -158,6 +158,24 @@ export type TemplateTagInfo = { tags: string[] } +/** + * Detailed information about a single template tag. + */ +export type TemplateTag = { + /** + * Name of the tag. + */ + tag: string + /** + * Build identifier associated with this tag. + */ + buildId: string + /** + * When this tag was assigned. + */ + createdAt: Date +} + /** * Types of instructions that can be used in a template. */ diff --git a/packages/js-sdk/tests/template/tags.test.ts b/packages/js-sdk/tests/template/tags.test.ts index 04767457b7..38b077b88a 100644 --- a/packages/js-sdk/tests/template/tags.test.ts +++ b/packages/js-sdk/tests/template/tags.test.ts @@ -18,6 +18,28 @@ const mockHandlers = [ tags: tags, }) }), + // Get template tags endpoint + http.get(apiUrl('/templates/:templateID/tags'), ({ params }) => { + const { templateID } = params + if (templateID === 'nonexistent') { + return HttpResponse.json( + { message: 'Template not found' }, + { status: 404 } + ) + } + return HttpResponse.json([ + { + tag: 'v1.0', + buildID: '00000000-0000-0000-0000-000000000000', + createdAt: '2024-01-15T10:30:00Z', + }, + { + tag: 'latest', + buildID: '11111111-1111-1111-1111-111111111111', + createdAt: '2024-01-16T12:00:00Z', + }, + ]) + }), // Bulk delete endpoint http.delete(apiUrl('/templates/tags'), async ({ request }) => { const { name } = (await request.clone().json()) as { @@ -81,6 +103,23 @@ describe('Template tags unit tests', () => { ).rejects.toThrow() }) }) + + describe('Template.getTags', () => { + test('returns tags for a template', async () => { + const tags = await Template.getTags('my-template-id') + expect(tags).toHaveLength(2) + expect(tags[0].tag).toBe('v1.0') + expect(tags[0].buildId).toBe('00000000-0000-0000-0000-000000000000') + expect(tags[0].createdAt).toBeInstanceOf(Date) + expect(tags[1].tag).toBe('latest') + expect(tags[1].buildId).toBe('11111111-1111-1111-1111-111111111111') + expect(tags[1].createdAt).toBeInstanceOf(Date) + }) + + test('handles 404 for nonexistent template', async () => { + await expect(Template.getTags('nonexistent')).rejects.toThrow() + }) + }) }) // Integration tests diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index c0a9316120..7bd56bb59e 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -103,6 +103,7 @@ CopyItem, TemplateBuildStatus, TemplateBuildStatusResponse, + TemplateTag, TemplateTagInfo, ) from .template_async.main import AsyncTemplate @@ -174,6 +175,7 @@ "BuildStatusReason", "TemplateBuildStatus", "TemplateBuildStatusResponse", + "TemplateTag", "TemplateTagInfo", "ReadyCmd", "wait_for_file", diff --git a/packages/python-sdk/e2b/api/client/api/tags/get_templates_template_id_tags.py b/packages/python-sdk/e2b/api/client/api/tags/get_templates_template_id_tags.py new file mode 100644 index 0000000000..1bc7dd7537 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/tags/get_templates_template_id_tags.py @@ -0,0 +1,168 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_tag import TemplateTag +from ...types import Response + + +def _get_kwargs( + template_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/templates/{template_id}/tags", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, list["TemplateTag"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = TemplateTag.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, list["TemplateTag"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, list["TemplateTag"]]]: + """List all tags for a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['TemplateTag']]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, list["TemplateTag"]]]: + """List all tags for a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['TemplateTag']] + """ + + return sync_detailed( + template_id=template_id, + client=client, + ).parsed + + +async def asyncio_detailed( + template_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, list["TemplateTag"]]]: + """List all tags for a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['TemplateTag']]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, list["TemplateTag"]]]: + """List all tags for a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['TemplateTag']] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + client=client, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/models/__init__.py b/packages/python-sdk/e2b/api/client/models/__init__.py index 163e03f977..1aebac9a0a 100644 --- a/packages/python-sdk/e2b/api/client/models/__init__.py +++ b/packages/python-sdk/e2b/api/client/models/__init__.py @@ -66,6 +66,7 @@ from .template_legacy import TemplateLegacy from .template_request_response_v3 import TemplateRequestResponseV3 from .template_step import TemplateStep +from .template_tag import TemplateTag from .template_update_request import TemplateUpdateRequest from .template_update_response import TemplateUpdateResponse from .template_with_builds import TemplateWithBuilds @@ -136,6 +137,7 @@ "TemplateLegacy", "TemplateRequestResponseV3", "TemplateStep", + "TemplateTag", "TemplateUpdateRequest", "TemplateUpdateResponse", "TemplateWithBuilds", diff --git a/packages/python-sdk/e2b/api/client/models/template_tag.py b/packages/python-sdk/e2b/api/client/models/template_tag.py new file mode 100644 index 0000000000..7718241281 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_tag.py @@ -0,0 +1,78 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +T = TypeVar("T", bound="TemplateTag") + + +@_attrs_define +class TemplateTag: + """ + Attributes: + build_id (UUID): Identifier of the build associated with this tag + created_at (datetime.datetime): When this tag was assigned + tag (str): Name of the tag + """ + + build_id: UUID + created_at: datetime.datetime + tag: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + build_id = str(self.build_id) + + created_at = self.created_at.isoformat() + + tag = self.tag + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "buildID": build_id, + "createdAt": created_at, + "tag": tag, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + build_id = UUID(d.pop("buildID")) + + created_at = isoparse(d.pop("createdAt")) + + tag = d.pop("tag") + + template_tag = cls( + build_id=build_id, + created_at=created_at, + tag=tag, + ) + + template_tag.additional_properties = d + return template_tag + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/template/types.py b/packages/python-sdk/e2b/template/types.py index b1ed5c6d18..05b6307d42 100644 --- a/packages/python-sdk/e2b/template/types.py +++ b/packages/python-sdk/e2b/template/types.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, field +from datetime import datetime from enum import Enum from pathlib import Path from typing import List, Literal, Optional, TypedDict, Union @@ -73,6 +74,22 @@ class TemplateTagInfo: """Assigned tags of the template.""" +@dataclass +class TemplateTag: + """ + Detailed information about a single template tag. + """ + + tag: str + """Name of the tag.""" + + build_id: str + """Build identifier associated with this tag.""" + + created_at: datetime + """When this tag was assigned.""" + + class InstructionType(str, Enum): """ Types of instructions that can be used in a template. diff --git a/packages/python-sdk/e2b/template_async/build_api.py b/packages/python-sdk/e2b/template_async/build_api.py index b45937a84a..7da04b1d89 100644 --- a/packages/python-sdk/e2b/template_async/build_api.py +++ b/packages/python-sdk/e2b/template_async/build_api.py @@ -15,6 +15,7 @@ from e2b.api.client.api.tags import ( post_templates_tags, delete_templates_tags, + get_templates_template_id_tags, ) from e2b.api.client.client import AuthenticatedClient from e2b.api.client.models import ( @@ -33,6 +34,7 @@ BuildStatusReason, TemplateBuildStatus, TemplateBuildStatusResponse, + TemplateTag, TemplateTagInfo, ) from e2b.template.utils import get_build_step_index, tar_file_stream @@ -336,3 +338,37 @@ async def remove_tags(client: AuthenticatedClient, name: str, tags: List[str]) - if res.status_code >= 300: raise handle_api_exception(res, TemplateException) + + +async def get_template_tags( + client: AuthenticatedClient, template_id: str +) -> List[TemplateTag]: + """ + Get all tags for a template. + + Args: + client: Authenticated API client + template_id: Template ID or name + """ + res = await get_templates_template_id_tags.asyncio_detailed( + template_id=template_id, + client=client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, TemplateException) + + if isinstance(res.parsed, Error): + raise TemplateException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise TemplateException("Failed to get template tags") + + return [ + TemplateTag( + tag=item.tag, + build_id=str(item.build_id), + created_at=item.created_at, + ) + for item in res.parsed + ] diff --git a/packages/python-sdk/e2b/template_async/main.py b/packages/python-sdk/e2b/template_async/main.py index a459af2cc9..b894ffb1fd 100644 --- a/packages/python-sdk/e2b/template_async/main.py +++ b/packages/python-sdk/e2b/template_async/main.py @@ -8,12 +8,13 @@ from e2b.template.consts import RESOLVE_SYMLINKS from e2b.template.logger import LogEntry, LogEntryEnd, LogEntryStart from e2b.template.main import TemplateBase, TemplateClass -from e2b.template.types import BuildInfo, InstructionType, TemplateTagInfo +from e2b.template.types import BuildInfo, InstructionType, TemplateTag, TemplateTagInfo from e2b.template.utils import normalize_build_arguments, read_dockerignore from .build_api import ( assign_tags, check_alias_exists, + get_template_tags, remove_tags, get_build_status, get_file_upload_link, @@ -496,3 +497,32 @@ async def remove_tags( normalized_tags = [tags] if isinstance(tags, str) else tags await remove_tags(api_client, name, normalized_tags) + + @staticmethod + async def get_tags( + template_id: str, + **opts: Unpack[ApiParams], + ) -> List[TemplateTag]: + """ + Get all tags for a template. + + :param template_id: Template ID or name + :return: List of TemplateTag with tag name, build_id, and created_at + + Example + ```python + from e2b import AsyncTemplate + + tags = await AsyncTemplate.get_tags('my-template') + for tag in tags: + print(f"Tag: {tag.tag}, Build: {tag.build_id}, Created: {tag.created_at}") + ``` + """ + config = ConnectionConfig(**opts) + api_client = get_api_client( + config, + require_api_key=True, + require_access_token=False, + ) + + return await get_template_tags(api_client, template_id) diff --git a/packages/python-sdk/e2b/template_sync/build_api.py b/packages/python-sdk/e2b/template_sync/build_api.py index 87e10e95d2..22f0be9033 100644 --- a/packages/python-sdk/e2b/template_sync/build_api.py +++ b/packages/python-sdk/e2b/template_sync/build_api.py @@ -15,6 +15,7 @@ from e2b.api.client.api.tags import ( post_templates_tags, delete_templates_tags, + get_templates_template_id_tags, ) from e2b.api.client.client import AuthenticatedClient from e2b.api.client.models import ( @@ -33,6 +34,7 @@ BuildStatusReason, TemplateBuildStatus, TemplateBuildStatusResponse, + TemplateTag, TemplateTagInfo, ) from e2b.template.utils import get_build_step_index, tar_file_stream @@ -333,3 +335,37 @@ def remove_tags(client: AuthenticatedClient, name: str, tags: List[str]) -> None if res.status_code >= 300: raise handle_api_exception(res, TemplateException) + + +def get_template_tags( + client: AuthenticatedClient, template_id: str +) -> List[TemplateTag]: + """ + Get all tags for a template. + + Args: + client: Authenticated API client + template_id: Template ID or name + """ + res = get_templates_template_id_tags.sync_detailed( + template_id=template_id, + client=client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, TemplateException) + + if isinstance(res.parsed, Error): + raise TemplateException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise TemplateException("Failed to get template tags") + + return [ + TemplateTag( + tag=item.tag, + build_id=str(item.build_id), + created_at=item.created_at, + ) + for item in res.parsed + ] diff --git a/packages/python-sdk/e2b/template_sync/main.py b/packages/python-sdk/e2b/template_sync/main.py index 1278e4d0c6..0758821789 100644 --- a/packages/python-sdk/e2b/template_sync/main.py +++ b/packages/python-sdk/e2b/template_sync/main.py @@ -10,10 +10,11 @@ from e2b.template.consts import RESOLVE_SYMLINKS from e2b.template.logger import LogEntry, LogEntryEnd, LogEntryStart from e2b.template.main import TemplateBase, TemplateClass -from e2b.template.types import BuildInfo, InstructionType, TemplateTagInfo +from e2b.template.types import BuildInfo, InstructionType, TemplateTag, TemplateTagInfo from e2b.template_sync.build_api import ( assign_tags, check_alias_exists, + get_template_tags, remove_tags, get_build_status, get_file_upload_link, @@ -497,3 +498,32 @@ def remove_tags( normalized_tags = [tags] if isinstance(tags, str) else tags remove_tags(api_client, name, normalized_tags) + + @staticmethod + def get_tags( + template_id: str, + **opts: Unpack[ApiParams], + ) -> List[TemplateTag]: + """ + Get all tags for a template. + + :param template_id: Template ID or name + :return: List of TemplateTag with tag name, build_id, and created_at + + Example + ```python + from e2b import Template + + tags = Template.get_tags('my-template') + for tag in tags: + print(f"Tag: {tag.tag}, Build: {tag.build_id}, Created: {tag.created_at}") + ``` + """ + config = ConnectionConfig(**opts) + api_client = get_api_client( + config, + require_api_key=True, + require_access_token=False, + ) + + return get_template_tags(api_client, template_id) diff --git a/packages/python-sdk/tests/async/template_async/test_stacktrace.py b/packages/python-sdk/tests/async/template_async/test_stacktrace.py index 03bc2ca162..29dcde11b0 100644 --- a/packages/python-sdk/tests/async/template_async/test_stacktrace.py +++ b/packages/python-sdk/tests/async/template_async/test_stacktrace.py @@ -182,9 +182,9 @@ async def test_traces_on_copyItems(async_build): @pytest.mark.skip_debug() async def test_traces_on_copy_absolute_path(): await _expect_to_throw_and_check_trace( - lambda: AsyncTemplate() - .from_base_image() - .copy("/absolute/path", "/absolute/path"), + lambda: ( + AsyncTemplate().from_base_image().copy("/absolute/path", "/absolute/path") + ), "copy", ) @@ -192,9 +192,11 @@ async def test_traces_on_copy_absolute_path(): @pytest.mark.skip_debug() async def test_traces_on_copyItems_absolute_path(): await _expect_to_throw_and_check_trace( - lambda: AsyncTemplate() - .from_base_image() - .copy_items([CopyItem(src="/absolute/path", dest="/absolute/path")]), + lambda: ( + AsyncTemplate() + .from_base_image() + .copy_items([CopyItem(src="/absolute/path", dest="/absolute/path")]) + ), "copy_items", ) diff --git a/packages/python-sdk/tests/async/template_async/test_tags.py b/packages/python-sdk/tests/async/template_async/test_tags.py index c25ce42819..d5cf211375 100644 --- a/packages/python-sdk/tests/async/template_async/test_tags.py +++ b/packages/python-sdk/tests/async/template_async/test_tags.py @@ -1,9 +1,10 @@ import uuid +from datetime import datetime, timezone from unittest.mock import AsyncMock import pytest -from e2b import AsyncTemplate, TemplateTagInfo, Template +from e2b import AsyncTemplate, TemplateTag, TemplateTagInfo, Template from e2b.exceptions import TemplateException import e2b.template_async.main as template_async_main @@ -115,6 +116,61 @@ async def test_remove_tags_error(self, monkeypatch): await AsyncTemplate.remove_tags("nonexistent", ["tag"]) +class TestGetTags: + """Tests for AsyncTemplate.get_tags method.""" + + @pytest.mark.asyncio + async def test_get_tags(self, monkeypatch): + """Test getting tags for a template.""" + mock_get_template_tags = AsyncMock( + return_value=[ + TemplateTag( + tag="v1.0", + build_id="00000000-0000-0000-0000-000000000000", + created_at=datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + ), + TemplateTag( + tag="latest", + build_id="11111111-1111-1111-1111-111111111111", + created_at=datetime(2024, 1, 16, 12, 0, 0, tzinfo=timezone.utc), + ), + ] + ) + + monkeypatch.setattr( + template_async_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr( + template_async_main, "get_template_tags", mock_get_template_tags + ) + + result = await AsyncTemplate.get_tags("my-template") + + assert len(result) == 2 + assert result[0].tag == "v1.0" + assert result[0].build_id == "00000000-0000-0000-0000-000000000000" + assert isinstance(result[0].created_at, datetime) + assert result[1].tag == "latest" + mock_get_template_tags.assert_called_once() + + @pytest.mark.asyncio + async def test_get_tags_error(self, monkeypatch): + """Test that get_tags raises an error for nonexistent template.""" + mock_get_template_tags = AsyncMock( + side_effect=TemplateException("Template not found") + ) + + monkeypatch.setattr( + template_async_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr( + template_async_main, "get_template_tags", mock_get_template_tags + ) + + with pytest.raises(TemplateException): + await AsyncTemplate.get_tags("nonexistent") + + # Integration tests class TestTagsIntegration: """Integration tests for AsyncTemplate tags functionality.""" diff --git a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py index 5801247523..c2dd79028f 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py +++ b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py @@ -192,9 +192,11 @@ def test_traces_on_copy_absolute_path(): @pytest.mark.skip_debug() def test_traces_on_copyItems_absolute_path(): _expect_to_throw_and_check_trace( - lambda: Template() - .from_base_image() - .copy_items([CopyItem(src="/absolute/path", dest="/absolute/path")]), + lambda: ( + Template() + .from_base_image() + .copy_items([CopyItem(src="/absolute/path", dest="/absolute/path")]) + ), "copy_items", ) diff --git a/packages/python-sdk/tests/sync/template_sync/test_tags.py b/packages/python-sdk/tests/sync/template_sync/test_tags.py index d2648894ab..f586e9b8c6 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_tags.py +++ b/packages/python-sdk/tests/sync/template_sync/test_tags.py @@ -1,9 +1,10 @@ import uuid +from datetime import datetime, timezone from unittest.mock import Mock import pytest -from e2b import TemplateTagInfo, Template +from e2b import TemplateTag, TemplateTagInfo, Template from e2b.exceptions import TemplateException import e2b.template_sync.main as template_sync_main @@ -106,6 +107,59 @@ def test_remove_tags_error(self, monkeypatch): Template.remove_tags("nonexistent", ["tag"]) +class TestGetTags: + """Tests for Template.get_tags method.""" + + def test_get_tags(self, monkeypatch): + """Test getting tags for a template.""" + mock_get_template_tags = Mock( + return_value=[ + TemplateTag( + tag="v1.0", + build_id="00000000-0000-0000-0000-000000000000", + created_at=datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + ), + TemplateTag( + tag="latest", + build_id="11111111-1111-1111-1111-111111111111", + created_at=datetime(2024, 1, 16, 12, 0, 0, tzinfo=timezone.utc), + ), + ] + ) + + monkeypatch.setattr( + template_sync_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr( + template_sync_main, "get_template_tags", mock_get_template_tags + ) + + result = Template.get_tags("my-template") + + assert len(result) == 2 + assert result[0].tag == "v1.0" + assert result[0].build_id == "00000000-0000-0000-0000-000000000000" + assert isinstance(result[0].created_at, datetime) + assert result[1].tag == "latest" + mock_get_template_tags.assert_called_once() + + def test_get_tags_error(self, monkeypatch): + """Test that get_tags raises an error for nonexistent template.""" + mock_get_template_tags = Mock( + side_effect=TemplateException("Template not found") + ) + + monkeypatch.setattr( + template_sync_main, "get_api_client", lambda *args, **kwargs: None + ) + monkeypatch.setattr( + template_sync_main, "get_template_tags", mock_get_template_tags + ) + + with pytest.raises(TemplateException): + Template.get_tags("nonexistent") + + # Integration tests class TestTagsIntegration: """Integration tests for Template tags functionality.""" diff --git a/spec/openapi.yml b/spec/openapi.yml index dab7c95535..223179a599 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -1577,6 +1577,24 @@ components: items: type: string + TemplateTag: + required: + - tag + - buildID + - createdAt + properties: + tag: + type: string + description: Name of the tag + buildID: + type: string + format: uuid + description: Identifier of the build associated with this tag + createdAt: + type: string + format: date-time + description: When this tag was assigned + Error: required: - code @@ -2635,6 +2653,32 @@ paths: "500": $ref: "#/components/responses/500" + /templates/{templateID}/tags: + get: + description: List all tags for a template + tags: [tags] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/templateID" + responses: + "200": + description: Successfully listed template tags + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TemplateTag" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + /templates/aliases/{alias}: get: description: Check if template with given alias exists