diff --git a/.changeset/python-template-alias-encoding.md b/.changeset/python-template-alias-encoding.md new file mode 100644 index 0000000000..e2ac4f99d1 --- /dev/null +++ b/.changeset/python-template-alias-encoding.md @@ -0,0 +1,22 @@ +--- +"@e2b/python-sdk": patch +--- + +Fix URL encoding of namespaced template IDs and aliases in the Python SDK. + +Namespaced identifiers contain a slash (e.g. `namespace/name`), but the SDK +interpolated them into the request path without encoding, so a call like +`Template.exists("namespace/name")` hit `/templates/aliases/namespace/name` +instead of `/templates/aliases/namespace%2Fname`. Every template method that +takes a template ID or alias in the path — `exists` / `alias_exists`, +`get_tags`, and the build/upload/status calls — now percent-encodes the value, +matching the JavaScript SDK (which already encodes path parameters via +`encodeURIComponent`). + +```python +from e2b import Template + +# Namespaced templates now resolve correctly +Template.exists("my-team/my-template") +Template.get_tags("my-team/my-template") +``` diff --git a/packages/python-sdk/e2b/template/utils.py b/packages/python-sdk/e2b/template/utils.py index 09a7cfc178..ccb7aebd7d 100644 --- a/packages/python-sdk/e2b/template/utils.py +++ b/packages/python-sdk/e2b/template/utils.py @@ -9,11 +9,24 @@ import inspect from types import TracebackType, FrameType from typing import IO, List, Optional, Union +from urllib.parse import quote from e2b.exceptions import TemplateException from e2b.template.consts import BASE_STEP_NAME, FINALIZE_STEP_NAME +def encode_path_param(value: str) -> str: + """ + Percent-encode a value for use as a single URL path segment. + + Namespaced template IDs and aliases contain a slash (``namespace/name``), + which the generated client would otherwise pass through as a path separator. + Encoding mirrors the JS SDK's ``encodeURIComponent``; httpx preserves the + result without double-encoding. + """ + return quote(value, safe="") + + def make_traceback(caller_frame: Optional[FrameType]) -> Optional[TracebackType]: """ Create a TracebackType from a caller frame for error reporting. diff --git a/packages/python-sdk/e2b/template_async/build_api.py b/packages/python-sdk/e2b/template_async/build_api.py index c9209fa5df..31ab1e8033 100644 --- a/packages/python-sdk/e2b/template_async/build_api.py +++ b/packages/python-sdk/e2b/template_async/build_api.py @@ -40,7 +40,7 @@ TemplateTagInfo, ) from e2b.template.consts import FILE_UPLOAD_TIMEOUT_SECONDS -from e2b.template.utils import get_build_step_index, tar_file_stream +from e2b.template.utils import encode_path_param, get_build_step_index, tar_file_stream async def request_build( @@ -79,7 +79,7 @@ async def get_file_upload_link( stack_trace: Optional[TracebackType] = None, ) -> TemplateBuildFileUpload: res = await get_templates_template_id_files_hash.asyncio_detailed( - template_id=template_id, + template_id=encode_path_param(template_id), hash_=files_hash, client=client, ) @@ -169,7 +169,7 @@ async def trigger_build( template_data = TemplateBuildStartV2.from_dict(template) res = await post_v_2_templates_template_id_builds_build_id.asyncio_detailed( - template_id=template_id, + template_id=encode_path_param(template_id), build_id=build_id, client=client, body=template_data, @@ -210,7 +210,7 @@ async def get_build_status( client: AuthenticatedClient, template_id: str, build_id: str, logs_offset: int ) -> TemplateBuildStatusResponse: res = await get_templates_template_id_builds_build_id_status.asyncio_detailed( - template_id=template_id, + template_id=encode_path_param(template_id), build_id=build_id, client=client, logs_offset=logs_offset, @@ -307,7 +307,7 @@ async def check_alias_exists(client: AuthenticatedClient, alias: str) -> bool: True if the alias exists, False otherwise """ res = await get_templates_aliases_alias.asyncio_detailed( - alias=alias, + alias=encode_path_param(alias), client=client, ) @@ -396,7 +396,7 @@ async def get_template_tags( template_id: Template ID or name """ res = await get_templates_template_id_tags.asyncio_detailed( - template_id=template_id, + template_id=encode_path_param(template_id), client=client, ) diff --git a/packages/python-sdk/e2b/template_sync/build_api.py b/packages/python-sdk/e2b/template_sync/build_api.py index e838d87887..465c2c11f8 100644 --- a/packages/python-sdk/e2b/template_sync/build_api.py +++ b/packages/python-sdk/e2b/template_sync/build_api.py @@ -38,7 +38,7 @@ TemplateTagInfo, ) from e2b.template.consts import FILE_UPLOAD_TIMEOUT_SECONDS -from e2b.template.utils import get_build_step_index, tar_file_stream +from e2b.template.utils import encode_path_param, get_build_step_index, tar_file_stream def request_build( @@ -77,7 +77,7 @@ def get_file_upload_link( stack_trace: Optional[TracebackType] = None, ) -> TemplateBuildFileUpload: res = get_templates_template_id_files_hash.sync_detailed( - template_id=template_id, + template_id=encode_path_param(template_id), hash_=files_hash, client=client, ) @@ -161,7 +161,7 @@ def trigger_build( template_data = TemplateBuildStartV2.from_dict(template) res = post_v_2_templates_template_id_builds_build_id.sync_detailed( - template_id=template_id, + template_id=encode_path_param(template_id), build_id=build_id, client=client, body=template_data, @@ -202,7 +202,7 @@ def get_build_status( client: AuthenticatedClient, template_id: str, build_id: str, logs_offset: int ) -> TemplateBuildStatusResponse: res = get_templates_template_id_builds_build_id_status.sync_detailed( - template_id=template_id, + template_id=encode_path_param(template_id), build_id=build_id, client=client, logs_offset=logs_offset, @@ -297,7 +297,7 @@ def check_alias_exists(client: AuthenticatedClient, alias: str) -> bool: True if the alias exists, False otherwise """ res = get_templates_aliases_alias.sync_detailed( - alias=alias, + alias=encode_path_param(alias), client=client, ) @@ -386,7 +386,7 @@ def get_template_tags( template_id: Template ID or name """ res = get_templates_template_id_tags.sync_detailed( - template_id=template_id, + template_id=encode_path_param(template_id), client=client, ) diff --git a/packages/python-sdk/tests/shared/template/utils/test_encode_path_param.py b/packages/python-sdk/tests/shared/template/utils/test_encode_path_param.py new file mode 100644 index 0000000000..4b93bee632 --- /dev/null +++ b/packages/python-sdk/tests/shared/template/utils/test_encode_path_param.py @@ -0,0 +1,26 @@ +from e2b.template.utils import encode_path_param + + +def test_leaves_simple_names_unchanged(): + assert encode_path_param("my-template") == "my-template" + + +def test_encodes_slash_in_namespaced_id(): + # Namespaced template IDs / aliases must have their slash percent-encoded + # so the whole value stays a single path segment. + assert encode_path_param("namespace/name") == "namespace%2Fname" + + +def test_encodes_every_slash_in_deeply_namespaced_id(): + assert encode_path_param("a/b/c") == "a%2Fb%2Fc" + + +def test_keeps_unreserved_characters_unencoded(): + # RFC 3986 unreserved characters must not be encoded. + assert encode_path_param("a-b_c.d~e") == "a-b_c.d~e" + + +def test_encodes_other_reserved_characters(): + # Mirrors JS `encodeURIComponent`, which also encodes ":" and spaces. + assert encode_path_param("name:tag") == "name%3Atag" + assert encode_path_param("a b") == "a%20b"