diff --git a/leap0/__init__.py b/leap0/__init__.py index c4eb815..c7133ac 100644 --- a/leap0/__init__.py +++ b/leap0/__init__.py @@ -82,14 +82,20 @@ from .models.snapshot import CreateSnapshotParams, ResumeSnapshotParams, Snapshot, snapshot_id_of from .models.ssh import SshAccess, SshValidation from .models.template import ( + AwsRegistryCredentials, AwsRegistryCredentialsDict, + AzureRegistryCredentials, AzureRegistryCredentialsDict, + BasicRegistryCredentials, BasicRegistryCredentialsDict, CreateTemplateParams, + GcpRegistryCredentials, GcpRegistryCredentialsDict, ImageConfig, RegistryCredentialType, + RegistryCredentials, RegistryCredentialsDict, + RegistryCredentialsInput, RenameTemplateParams, Template, ) @@ -121,8 +127,11 @@ "AsyncSnapshotsClient", "AsyncSshClient", "AsyncTemplatesClient", + "AwsRegistryCredentials", "AwsRegistryCredentialsDict", + "AzureRegistryCredentials", "AzureRegistryCredentialsDict", + "BasicRegistryCredentials", "BasicRegistryCredentialsDict", "CodeContext", "CodeExecutionError", @@ -151,6 +160,7 @@ "FileEdit", "FileInfo", "FilesystemClient", + "GcpRegistryCredentials", "GitClient", "GitCommitResult", "GitResult", @@ -181,7 +191,9 @@ "PtySession", "RenameTemplateParams", "RegistryCredentialType", + "RegistryCredentials", "RegistryCredentialsDict", + "RegistryCredentialsInput", "ResumeSnapshotParams", "Sandbox", "SandboxState", @@ -273,13 +285,19 @@ "SshAccess": (".models.ssh", "SshAccess"), "SshValidation": (".models.ssh", "SshValidation"), "CreateTemplateParams": (".models.template", "CreateTemplateParams"), + "BasicRegistryCredentials": (".models.template", "BasicRegistryCredentials"), "BasicRegistryCredentialsDict": (".models.template", "BasicRegistryCredentialsDict"), + "AwsRegistryCredentials": (".models.template", "AwsRegistryCredentials"), "AwsRegistryCredentialsDict": (".models.template", "AwsRegistryCredentialsDict"), + "GcpRegistryCredentials": (".models.template", "GcpRegistryCredentials"), "GcpRegistryCredentialsDict": (".models.template", "GcpRegistryCredentialsDict"), + "AzureRegistryCredentials": (".models.template", "AzureRegistryCredentials"), "AzureRegistryCredentialsDict": (".models.template", "AzureRegistryCredentialsDict"), "ImageConfig": (".models.template", "ImageConfig"), "RegistryCredentialType": (".models.template", "RegistryCredentialType"), + "RegistryCredentials": (".models.template", "RegistryCredentials"), "RegistryCredentialsDict": (".models.template", "RegistryCredentialsDict"), + "RegistryCredentialsInput": (".models.template", "RegistryCredentialsInput"), "Template": (".models.template", "Template"), "RenameTemplateParams": (".models.template", "RenameTemplateParams"), "CodeContext": (".models.code_interpreter", "CodeContext"), @@ -316,4 +334,3 @@ def __getattr__(name: str) -> object: def __dir__() -> list[str]: return sorted(__all__) - "GcpRegistryCredentialsDict", diff --git a/leap0/_async/client.py b/leap0/_async/client.py index 9e90ddd..1db6548 100644 --- a/leap0/_async/client.py +++ b/leap0/_async/client.py @@ -71,20 +71,13 @@ def _reset_meter_provider_if_current(provider: MeterProvider) -> None: class AsyncLeap0Client: """Top-level asynchronous client for the Leap0 API. - Use this client to create sandboxes and access all async service clients. + Use this client to create sandboxes and access top-level control-plane + services. Sandbox-scoped services are exposed through bound sandbox objects. Attributes: sandboxes: Client for sandbox lifecycle operations. snapshots: Client for snapshot lifecycle operations. templates: Client for template management. - filesystem: Client for sandbox filesystem operations. - git: Client for Git operations inside a sandbox. - process: Client for one-shot process execution. - pty: Client for interactive PTY sessions. - lsp: Client for Language Server Protocol operations. - ssh: Client for SSH credential management. - code_interpreter: Client for code execution APIs. - desktop: Client for desktop automation APIs. """ DEFAULT_BASE_URL = DEFAULT_BASE_URL DEFAULT_SANDBOX_DOMAIN = DEFAULT_SANDBOX_DOMAIN @@ -155,14 +148,14 @@ def __init__( sandbox_factory=lambda data: AsyncSandbox(self, data), ) self.templates = AsyncTemplatesClient(self._transport) - self.filesystem = AsyncFilesystemClient(self._transport) - self.git = AsyncGitClient(self._transport) - self.process = AsyncProcessClient(self._transport) - self.pty = AsyncPtyClient(self._transport) - self.lsp = AsyncLspClient(self._transport) - self.ssh = AsyncSshClient(self._transport) - self.code_interpreter = AsyncCodeInterpreterClient(self._transport, sandbox_domain=config.sandbox_domain) - self.desktop = AsyncDesktopClient(self._transport, sandbox_domain=config.sandbox_domain) + self._filesystem = AsyncFilesystemClient(self._transport) + self._git = AsyncGitClient(self._transport) + self._process = AsyncProcessClient(self._transport) + self._pty = AsyncPtyClient(self._transport) + self._lsp = AsyncLspClient(self._transport) + self._ssh = AsyncSshClient(self._transport) + self._code_interpreter = AsyncCodeInterpreterClient(self._transport, sandbox_domain=config.sandbox_domain) + self._desktop = AsyncDesktopClient(self._transport, sandbox_domain=config.sandbox_domain) if config.sdk_otel_enabled: self._init_otel() @@ -209,6 +202,14 @@ def _init_otel(self) -> None: self._meter_provider = _shared_meter_provider self._uses_shared_meter_provider = True + def __getattr__(self, name: str) -> object: + if name in {"filesystem", "git", "process", "pty", "lsp", "ssh", "code_interpreter", "desktop"}: + raise AttributeError( + f"{type(self).__name__!s} has no attribute {name!r}; use a bound sandbox handle instead, " + f"for example sandbox.{name}" + ) + raise AttributeError(f"{type(self).__name__!s} has no attribute {name!r}") + @with_instrumentation("async_client.get_sandbox") async def get_sandbox(self, sandbox_id: str) -> AsyncSandbox: """Get a sandbox by ID. diff --git a/leap0/_async/filesystem.py b/leap0/_async/filesystem.py index 3f310db..8a22ddc 100644 --- a/leap0/_async/filesystem.py +++ b/leap0/_async/filesystem.py @@ -94,8 +94,7 @@ async def write_bytes(self, sandbox: SandboxRef, *, path: str, content: bytes, p http_timeout: Optional HTTP request timeout in seconds for this SDK call. Example: ```python - await client.filesystem.write_bytes( - sandbox, + await sandbox.filesystem.write_bytes( path="/workspace/logo.png", content=image_bytes, ) @@ -128,8 +127,7 @@ async def write_file(self, sandbox: SandboxRef, *, path: str, content: str, enco http_timeout: Optional HTTP request timeout in seconds for this SDK call. Example: ```python - await client.filesystem.write_file( - sandbox, + await sandbox.filesystem.write_file( path="/workspace/app.py", content="print('hello')\n", ) @@ -154,8 +152,7 @@ async def write_files_bytes(self, sandbox: SandboxRef, *, files: dict[str, bytes http_timeout: Optional HTTP request timeout in seconds for this SDK call. Example: ```python - await client.filesystem.write_files_bytes( - sandbox, + await sandbox.filesystem.write_files_bytes( files={"/workspace/a.bin": b"a", "/workspace/b.bin": b"b"}, ) ``` diff --git a/leap0/_async/process.py b/leap0/_async/process.py index 5586bfc..04858a7 100644 --- a/leap0/_async/process.py +++ b/leap0/_async/process.py @@ -40,8 +40,7 @@ async def execute(self, sandbox: SandboxRef, *, command: str, cwd: str | None = Example: ```python - result = await client.process.execute( - sandbox, + result = await sandbox.process.execute( command="ls -la /workspace", ) print(result.stdout) diff --git a/leap0/_async/sandbox.py b/leap0/_async/sandbox.py index 73e884d..15a804a 100644 --- a/leap0/_async/sandbox.py +++ b/leap0/_async/sandbox.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Generic, Protocol, TypeVar, cast from ..constants import OTEL_EXPORTER_OTLP_ENDPOINT_ENV, OTEL_EXPORTER_OTLP_HEADERS_ENV -from .._internal.types import SandboxFactory +from .._internal.types import SandboxFactory, SandboxHandle from ..models.config import ( DEFAULT_MEMORY_MIB, DEFAULT_TEMPLATE_NAME, @@ -60,7 +60,7 @@ class _AsyncBoundSandboxCallable(Protocol): async def __call__(self, sandbox: object, *args: object, **kwargs: object) -> object: ... -class AsyncSandbox: +class AsyncSandbox(SandboxHandle): """Sandbox object with bound asynchronous service clients. Attributes: @@ -76,14 +76,14 @@ class AsyncSandbox: def __init__(self, client: "AsyncLeap0Client", data: SandboxData | SandboxStatus): self._client: "AsyncLeap0Client" = client self._data: SandboxData | SandboxStatus = data - self.filesystem = _AsyncSandboxServiceProxy(client.filesystem, self) - self.git = _AsyncSandboxServiceProxy(client.git, self) - self.process = _AsyncSandboxServiceProxy(client.process, self) - self.pty = _AsyncSandboxServiceProxy(client.pty, self) - self.lsp = _AsyncSandboxServiceProxy(client.lsp, self) - self.ssh = _AsyncSandboxServiceProxy(client.ssh, self) - self.code_interpreter = _AsyncSandboxServiceProxy(client.code_interpreter, self) - self.desktop = _AsyncSandboxServiceProxy(client.desktop, self) + self.filesystem = _AsyncSandboxServiceProxy(client._filesystem, self) + self.git = _AsyncSandboxServiceProxy(client._git, self) + self.process = _AsyncSandboxServiceProxy(client._process, self) + self.pty = _AsyncSandboxServiceProxy(client._pty, self) + self.lsp = _AsyncSandboxServiceProxy(client._lsp, self) + self.ssh = _AsyncSandboxServiceProxy(client._ssh, self) + self.code_interpreter = _AsyncSandboxServiceProxy(client._code_interpreter, self) + self.desktop = _AsyncSandboxServiceProxy(client._desktop, self) def __getattr__(self, name: str) -> object: return getattr(self._data, name) diff --git a/leap0/_async/templates.py b/leap0/_async/templates.py index e1e7a30..78f4737 100644 --- a/leap0/_async/templates.py +++ b/leap0/_async/templates.py @@ -4,6 +4,7 @@ from ..models.template import ( CreateTemplateParams, + RegistryCredentials, RegistryCredentialsDict, RenameTemplateParams, Template, @@ -36,7 +37,7 @@ def __init__(self, transport: AsyncTransport): self._transport = transport @intercept_errors("Failed to create template: ") - async def create(self, *, name: str, uri: str, credentials: RegistryCredentialsDict | None = None) -> Template: + async def create(self, *, name: str, uri: str, credentials: RegistryCredentials | RegistryCredentialsDict | None = None) -> Template: """Upload a new template from a container image URI. Args: diff --git a/leap0/_internal/types.py b/leap0/_internal/types.py index 4c809e0..b0ff276 100644 --- a/leap0/_internal/types.py +++ b/leap0/_internal/types.py @@ -16,6 +16,12 @@ class SandboxFactory(Protocol[SandboxModelT, SandboxReturnT]): def __call__(self, data: SandboxModelT) -> SandboxReturnT: ... +class SandboxHandle: + """Nominal base type for SDK sandbox references.""" + + id: str + + class SyncSandboxService(Protocol): """Protocol for sandbox-bound synchronous service callables.""" def __call__(self, sandbox: object, *args: object, **kwargs: object) -> object: ... diff --git a/leap0/_sync/client.py b/leap0/_sync/client.py index 34871ac..b534ebd 100644 --- a/leap0/_sync/client.py +++ b/leap0/_sync/client.py @@ -44,8 +44,9 @@ class Leap0Client: """Top-level client for the Leap0 API. - Use this client to create sandboxes and access all service clients. It can - be used directly or as a context manager. + Use this client to create sandboxes and access top-level control-plane + services. Sandbox-scoped services are exposed through bound sandbox objects. + It can be used directly or as a context manager. Args: api_key: API key for authentication. Falls back to ``LEAP0_API_KEY``. @@ -60,14 +61,6 @@ class Leap0Client: sandboxes: Client for sandbox lifecycle operations. snapshots: Client for snapshot lifecycle operations. templates: Client for template management. - filesystem: Client for sandbox filesystem operations. - git: Client for Git operations inside a sandbox. - process: Client for one-shot process execution. - pty: Client for interactive PTY sessions. - lsp: Client for Language Server Protocol operations. - ssh: Client for SSH credential management. - code_interpreter: Client for code execution APIs. - desktop: Client for desktop automation APIs. """ DEFAULT_BASE_URL = DEFAULT_BASE_URL DEFAULT_SANDBOX_DOMAIN = DEFAULT_SANDBOX_DOMAIN @@ -129,14 +122,14 @@ def __init__( sandbox_factory=lambda data: Sandbox(self, data), ) self.templates = TemplatesClient(self._transport) - self.filesystem = FilesystemClient(self._transport) - self.git = GitClient(self._transport) - self.process = ProcessClient(self._transport) - self.pty = PtyClient(self._transport) - self.lsp = LspClient(self._transport) - self.ssh = SshClient(self._transport) - self.code_interpreter = CodeInterpreterClient(self._transport, sandbox_domain=config.sandbox_domain) - self.desktop = DesktopClient(self._transport, sandbox_domain=config.sandbox_domain) + self._filesystem = FilesystemClient(self._transport) + self._git = GitClient(self._transport) + self._process = ProcessClient(self._transport) + self._pty = PtyClient(self._transport) + self._lsp = LspClient(self._transport) + self._ssh = SshClient(self._transport) + self._code_interpreter = CodeInterpreterClient(self._transport, sandbox_domain=config.sandbox_domain) + self._desktop = DesktopClient(self._transport, sandbox_domain=config.sandbox_domain) if config.sdk_otel_enabled: self._init_otel() @@ -168,6 +161,14 @@ def _init_otel(self) -> None: else: self._meter_provider = current_meter_provider + def __getattr__(self, name: str) -> object: + if name in {"filesystem", "git", "process", "pty", "lsp", "ssh", "code_interpreter", "desktop"}: + raise AttributeError( + f"{type(self).__name__!s} has no attribute {name!r}; use a bound sandbox handle instead, " + f"for example sandbox.{name}" + ) + raise AttributeError(f"{type(self).__name__!s} has no attribute {name!r}") + @with_instrumentation("client.get_sandbox") def get_sandbox(self, sandbox_id: str) -> Sandbox: """Get a sandbox object with bound service clients by ID. diff --git a/leap0/_sync/code_interpreter.py b/leap0/_sync/code_interpreter.py index 72ca010..80b3e04 100644 --- a/leap0/_sync/code_interpreter.py +++ b/leap0/_sync/code_interpreter.py @@ -25,8 +25,7 @@ class CodeInterpreterClient: Example: ```python - result = client.code_interpreter.execute( - sandbox, + result = sandbox.code_interpreter.execute( code="sum([1, 2, 3])", language="python", ) diff --git a/leap0/_sync/filesystem.py b/leap0/_sync/filesystem.py index e64fa60..f9e8692 100644 --- a/leap0/_sync/filesystem.py +++ b/leap0/_sync/filesystem.py @@ -92,8 +92,7 @@ def write_bytes(self, sandbox: SandboxRef, *, path: str, content: bytes, permiss http_timeout: Optional HTTP request timeout in seconds for this SDK call. Example: ```python - client.filesystem.write_bytes( - sandbox, + sandbox.filesystem.write_bytes( path="/workspace/logo.png", content=image_bytes, ) @@ -126,8 +125,7 @@ def write_file(self, sandbox: SandboxRef, *, path: str, content: str, encoding: http_timeout: Optional HTTP request timeout in seconds for this SDK call. Example: ```python - client.filesystem.write_file( - sandbox, + sandbox.filesystem.write_file( path="/workspace/app.py", content="print('hello')\n", ) @@ -152,8 +150,7 @@ def write_files_bytes(self, sandbox: SandboxRef, *, files: dict[str, bytes], htt http_timeout: Optional HTTP request timeout in seconds for this SDK call. Example: ```python - client.filesystem.write_files_bytes( - sandbox, + sandbox.filesystem.write_files_bytes( files={"/workspace/a.bin": b"a", "/workspace/b.bin": b"b"}, ) ``` diff --git a/leap0/_sync/process.py b/leap0/_sync/process.py index 3140df9..861f1e7 100644 --- a/leap0/_sync/process.py +++ b/leap0/_sync/process.py @@ -41,8 +41,7 @@ def execute(self, sandbox: SandboxRef, *, command: str, cwd: str | None = None, Example: ```python - result = client.process.execute( - sandbox, + result = sandbox.process.execute( command="ls -la /workspace", ) print(result.stdout) diff --git a/leap0/_sync/sandbox.py b/leap0/_sync/sandbox.py index b3d8a6c..dd31cbc 100644 --- a/leap0/_sync/sandbox.py +++ b/leap0/_sync/sandbox.py @@ -5,7 +5,7 @@ from typing import Generic, Protocol, TypeVar, cast from ..constants import OTEL_EXPORTER_OTLP_ENDPOINT_ENV, OTEL_EXPORTER_OTLP_HEADERS_ENV -from .._internal.types import SandboxFactory +from .._internal.types import SandboxFactory, SandboxHandle from ..models.config import ( DEFAULT_MEMORY_MIB, DEFAULT_TEMPLATE_NAME, @@ -52,7 +52,7 @@ def bound(*args: object, **kwargs: object) -> object: return bound -class Sandbox: +class Sandbox(SandboxHandle): """Sandbox object with bound service clients. This object exposes sandbox metadata directly and provides bound service @@ -73,14 +73,14 @@ class Sandbox: def __init__(self, client: object, data: SandboxData | SandboxStatus): self._client = client self._data: SandboxData | SandboxStatus = data - self.filesystem = _SandboxServiceProxy(client.filesystem, self) - self.git = _SandboxServiceProxy(client.git, self) - self.process = _SandboxServiceProxy(client.process, self) - self.pty = _SandboxServiceProxy(client.pty, self) - self.lsp = _SandboxServiceProxy(client.lsp, self) - self.ssh = _SandboxServiceProxy(client.ssh, self) - self.code_interpreter = _SandboxServiceProxy(client.code_interpreter, self) - self.desktop = _SandboxServiceProxy(client.desktop, self) + self.filesystem = _SandboxServiceProxy(client._filesystem, self) + self.git = _SandboxServiceProxy(client._git, self) + self.process = _SandboxServiceProxy(client._process, self) + self.pty = _SandboxServiceProxy(client._pty, self) + self.lsp = _SandboxServiceProxy(client._lsp, self) + self.ssh = _SandboxServiceProxy(client._ssh, self) + self.code_interpreter = _SandboxServiceProxy(client._code_interpreter, self) + self.desktop = _SandboxServiceProxy(client._desktop, self) def __getattr__(self, name: str) -> object: return getattr(self._data, name) diff --git a/leap0/_sync/templates.py b/leap0/_sync/templates.py index 0a4e655..5c94b81 100644 --- a/leap0/_sync/templates.py +++ b/leap0/_sync/templates.py @@ -4,6 +4,7 @@ from ..models.template import ( CreateTemplateParams, + RegistryCredentials, RegistryCredentialsDict, RenameTemplateParams, Template, @@ -36,7 +37,7 @@ def __init__(self, transport: Transport): self._transport = transport @intercept_errors("Failed to create template: ") - def create(self, *, name: str, uri: str, credentials: RegistryCredentialsDict | None = None) -> Template: + def create(self, *, name: str, uri: str, credentials: RegistryCredentials | RegistryCredentialsDict | None = None) -> Template: """Upload a new template from a container image URI. Args: diff --git a/leap0/models/sandbox.py b/leap0/models/sandbox.py index f6e8804..9ffdb0d 100644 --- a/leap0/models/sandbox.py +++ b/leap0/models/sandbox.py @@ -2,10 +2,11 @@ from dataclasses import dataclass from enum import Enum -from typing import Protocol +from typing import TypeAlias from pydantic import BaseModel, ConfigDict, model_validator +from .._internal.types import SandboxHandle from .._internal.types import JsonObject from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict, SandboxStatusResponseDict, TransformRuleDict from .config import DEFAULT_MEMORY_MIB, DEFAULT_TEMPLATE_NAME, DEFAULT_TIMEOUT_MIN, DEFAULT_VCPU @@ -65,7 +66,7 @@ def to_payload(self) -> JsonObject: CreateSandboxParams.model_rebuild(_types_namespace={"NetworkPolicyMode": NetworkPolicyMode}) @dataclass(slots=True) -class Sandbox: +class Sandbox(SandboxHandle): """Sandbox model returned by sandbox creation APIs.""" id: str template_id: str = "" @@ -97,7 +98,7 @@ def from_dict(cls, data: SandboxCreateResponseDict) -> Sandbox: ) @dataclass(slots=True) -class SandboxStatus: +class SandboxStatus(SandboxHandle): """Current status snapshot for a sandbox.""" id: str template_id: str @@ -126,12 +127,7 @@ def from_dict(cls, data: SandboxStatusResponseDict) -> SandboxStatus: created_at=data.get("created_at", ""), ) -class SandboxIdentifiable(Protocol): - """Protocol for objects exposing a sandbox ID.""" - id: str - - -SandboxRef = str | SandboxIdentifiable +SandboxRef: TypeAlias = str | SandboxHandle def _parse_sandbox_state(value: SandboxState | str | None) -> SandboxState | str: if value is None: @@ -145,4 +141,7 @@ def sandbox_id_of(value: SandboxRef) -> str: """Return the sandbox ID for a sandbox reference.""" if isinstance(value, str): return value - return str(value.id) + if isinstance(value, SandboxHandle): + return value.id + + raise TypeError("sandbox must be a sandbox id or SDK sandbox handle") diff --git a/leap0/models/template.py b/leap0/models/template.py index 5f3fc7c..6ab81be 100644 --- a/leap0/models/template.py +++ b/leap0/models/template.py @@ -2,8 +2,9 @@ from dataclasses import dataclass from enum import Enum -from typing import TypeAlias -from pydantic import BaseModel, ConfigDict, model_validator +from typing import Annotated, Literal, TypeAlias + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from .._schemas.template import ( AwsRegistryCredentialsDict as _AwsRegistryCredentialsDict, AzureRegistryCredentialsDict as _AzureRegistryCredentialsDict, @@ -24,6 +25,13 @@ | AzureRegistryCredentialsDict ) + +class _RegistryCredentialsBase(BaseModel): + """Validated registry credentials.""" + + model_config = ConfigDict(extra="forbid") + + class RegistryCredentialType(str, Enum): """Supported container registry credential types.""" BASIC = "basic" @@ -31,13 +39,65 @@ class RegistryCredentialType(str, Enum): GCP = "gcp" AZURE = "azure" + +class BasicRegistryCredentials(_RegistryCredentialsBase): + """Validated basic-auth registry credentials.""" + + type: Literal["basic"] = RegistryCredentialType.BASIC.value + username: str + password: str + + +class AwsRegistryCredentials(_RegistryCredentialsBase): + """Validated AWS registry credentials.""" + + type: Literal["aws"] = RegistryCredentialType.AWS.value + aws_access_key_id: str + aws_secret_access_key: str + aws_region: str | None = None + + +class GcpRegistryCredentials(_RegistryCredentialsBase): + """Validated GCP registry credentials.""" + + type: Literal["gcp"] = RegistryCredentialType.GCP.value + gcp_service_account_json: str + + +class AzureRegistryCredentials(_RegistryCredentialsBase): + """Validated Azure registry credentials.""" + + type: Literal["azure"] = RegistryCredentialType.AZURE.value + azure_client_id: str + azure_client_secret: str + azure_tenant_id: str + + +RegistryCredentials: TypeAlias = Annotated[ + BasicRegistryCredentials | AwsRegistryCredentials | GcpRegistryCredentials | AzureRegistryCredentials, + Field(discriminator="type"), +] +RegistryCredentialsInput: TypeAlias = RegistryCredentials | RegistryCredentialsDict + class CreateTemplateParams(BaseModel): """Validated template creation parameters.""" model_config = ConfigDict(extra="forbid") name: str uri: str - credentials: RegistryCredentialsDict | None = None + credentials: RegistryCredentials | None = None + + @field_validator("credentials", mode="before") + @classmethod + def _normalize_credentials(cls, value: object) -> object: + if not isinstance(value, dict): + return value + + normalized = dict(value) + credential_type = normalized.get("type") + if isinstance(credential_type, RegistryCredentialType): + normalized["type"] = credential_type.value + return normalized @model_validator(mode="after") def _validate_values(self) -> CreateTemplateParams: @@ -61,7 +121,7 @@ def _validate_values(self) -> CreateTemplateParams: def to_payload(self) -> dict[str, object]: """Convert this object to an API request payload.""" - return self.model_dump(exclude_none=True) + return self.model_dump(mode="json", exclude_none=True) class RenameTemplateParams(BaseModel): """Validated template rename parameters.""" @@ -86,10 +146,6 @@ def _validate_name(self) -> RenameTemplateParams: def to_payload(self) -> dict[str, str]: """Convert this object to an API request payload.""" return {"name": self.name} - - -CreateTemplateParams.model_rebuild(_types_namespace={"RegistryCredentialType": RegistryCredentialType}) - @dataclass(slots=True) class ImageConfig: """Container image configuration metadata.""" diff --git a/pyproject.toml b/pyproject.toml index 12a1876..2b65c50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "leap0" -version = "0.3.0" +version = "0.3.1" description = "Python SDK for the Leap0 API" requires-python = ">=3.10" license = { text = "Apache-2.0" } diff --git a/tests/_async/test_client.py b/tests/_async/test_client.py index dfaf106..834bcb4 100644 --- a/tests/_async/test_client.py +++ b/tests/_async/test_client.py @@ -22,6 +22,12 @@ def test_async_client_native_transport(monkeypatch): async def run() -> None: client = AsyncLeap0Client(api_key="test") + try: + _ = client.filesystem + except AttributeError as exc: + assert "sandbox.filesystem" in str(exc) + else: + raise AssertionError("expected sandbox-scoped service access to fail") async def fake_get(_sandbox_id: str) -> AsyncSandbox: return AsyncSandbox(client, Sandbox(id="sbx-1", state="running")) diff --git a/tests/_async/test_sandboxes.py b/tests/_async/test_sandboxes.py index 9e5c9a5..684e2d9 100644 --- a/tests/_async/test_sandboxes.py +++ b/tests/_async/test_sandboxes.py @@ -27,8 +27,8 @@ async def run() -> None: def test_factory_returns_async_sandbox(self, async_mock_transport): async def run() -> None: fake_client = SimpleNamespace( - filesystem=SimpleNamespace(), git=SimpleNamespace(), process=SimpleNamespace(), pty=SimpleNamespace(), - lsp=SimpleNamespace(), ssh=SimpleNamespace(), code_interpreter=SimpleNamespace(), desktop=SimpleNamespace(), + _filesystem=SimpleNamespace(), _git=SimpleNamespace(), _process=SimpleNamespace(), _pty=SimpleNamespace(), + _lsp=SimpleNamespace(), _ssh=SimpleNamespace(), _code_interpreter=SimpleNamespace(), _desktop=SimpleNamespace(), ) client = AsyncSandboxesClient(async_mock_transport, sandbox_domain="s.dev", sandbox_factory=lambda data: AsyncSandbox(fake_client, data)) async_mock_transport.request_json.return_value = { diff --git a/tests/_sync/test_sandboxes.py b/tests/_sync/test_sandboxes.py index 1030893..2926246 100644 --- a/tests/_sync/test_sandboxes.py +++ b/tests/_sync/test_sandboxes.py @@ -47,14 +47,14 @@ def test_invoke_url(self, mock_transport): def test_factory_returns_rich_sandbox(self, mock_transport): fake_client = SimpleNamespace( - filesystem=MagicMock(), - git=MagicMock(), - process=MagicMock(), - pty=MagicMock(), - lsp=MagicMock(), - ssh=MagicMock(), - code_interpreter=MagicMock(), - desktop=MagicMock(), + _filesystem=MagicMock(), + _git=MagicMock(), + _process=MagicMock(), + _pty=MagicMock(), + _lsp=MagicMock(), + _ssh=MagicMock(), + _code_interpreter=MagicMock(), + _desktop=MagicMock(), ) client = SandboxesClient( mock_transport, @@ -142,14 +142,14 @@ def test_bound_service_methods_pass_sandbox(self): process.execute.return_value = MagicMock(stdout="Python 3.12") sandboxes = MagicMock() client = SimpleNamespace( - filesystem=MagicMock(), - git=MagicMock(), - process=process, - pty=MagicMock(), - lsp=MagicMock(), - ssh=MagicMock(), - code_interpreter=MagicMock(), - desktop=MagicMock(), + _filesystem=MagicMock(), + _git=MagicMock(), + _process=process, + _pty=MagicMock(), + _lsp=MagicMock(), + _ssh=MagicMock(), + _code_interpreter=MagicMock(), + _desktop=MagicMock(), sandboxes=sandboxes, ) @@ -162,14 +162,14 @@ def test_bound_service_methods_pass_sandbox(self): def test_refresh_updates_metadata(self): sandboxes = MagicMock() client = SimpleNamespace( - filesystem=MagicMock(), - git=MagicMock(), - process=MagicMock(), - pty=MagicMock(), - lsp=MagicMock(), - ssh=MagicMock(), - code_interpreter=MagicMock(), - desktop=MagicMock(), + _filesystem=MagicMock(), + _git=MagicMock(), + _process=MagicMock(), + _pty=MagicMock(), + _lsp=MagicMock(), + _ssh=MagicMock(), + _code_interpreter=MagicMock(), + _desktop=MagicMock(), sandboxes=sandboxes, ) sandbox = RichSandbox(client, Sandbox(id="sbx-1", state="starting")) diff --git a/tests/models/test_config.py b/tests/models/test_config.py index e388ab4..116be0c 100644 --- a/tests/models/test_config.py +++ b/tests/models/test_config.py @@ -52,7 +52,8 @@ def test_raises_when_no_key(self): def test_creates_with_key(self): client = Leap0Client(api_key="test-key") assert client.sandboxes is not None - assert client.filesystem is not None + with pytest.raises(AttributeError, match=r"sandbox\.filesystem"): + _ = client.filesystem client.close() def test_context_manager(self): diff --git a/tests/models/test_sandbox.py b/tests/models/test_sandbox.py index 435d221..d2b2ba8 100644 --- a/tests/models/test_sandbox.py +++ b/tests/models/test_sandbox.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + from leap0.models.sandbox import Sandbox, SandboxStatus, sandbox_id_of @@ -15,6 +17,13 @@ def test_from_sandbox_status(self): disk_mib=10240, state="running", auto_pause=False, created_at="") assert sandbox_id_of(s) == "sbx-xyz" + def test_rejects_unrelated_object_with_id(self): + class FakeSandbox: + id = "sbx-fake" + + with pytest.raises(TypeError, match="sandbox must be"): + sandbox_id_of(FakeSandbox()) + class TestSandbox: def test_full_dict(self): @@ -41,6 +50,5 @@ def test_full_dict(self): assert s.vcpu == 4 def test_empty_dict_raises(self): - import pytest with pytest.raises(ValueError, match="missing required non-empty string 'id'"): SandboxStatus.from_dict({}) diff --git a/tests/models/test_template.py b/tests/models/test_template.py index 87e854c..6104a6d 100644 --- a/tests/models/test_template.py +++ b/tests/models/test_template.py @@ -1,6 +1,17 @@ from __future__ import annotations -from leap0.models.template import CreateTemplateParams, ImageConfig, RegistryCredentialType, Template +import pytest + +from leap0.models.template import ( + AwsRegistryCredentials, + AzureRegistryCredentials, + BasicRegistryCredentials, + CreateTemplateParams, + GcpRegistryCredentials, + ImageConfig, + RegistryCredentialType, + Template, +) class TestImageConfig: @@ -40,7 +51,8 @@ def test_accepts_basic_registry_credentials(self): "password": "my-password", }, ) - assert params.credentials["type"] == RegistryCredentialType.BASIC + assert isinstance(params.credentials, BasicRegistryCredentials) + assert params.credentials.type == "basic" def test_accepts_aws_registry_credentials(self): params = CreateTemplateParams( @@ -53,7 +65,8 @@ def test_accepts_aws_registry_credentials(self): "aws_region": "us-east-1", }, ) - assert params.credentials["type"] == RegistryCredentialType.AWS + assert isinstance(params.credentials, AwsRegistryCredentials) + assert params.credentials.type == "aws" def test_accepts_gcp_registry_credentials(self): params = CreateTemplateParams( @@ -64,7 +77,8 @@ def test_accepts_gcp_registry_credentials(self): "gcp_service_account_json": '{"type":"service_account"}', }, ) - assert params.credentials["type"] == RegistryCredentialType.GCP + assert isinstance(params.credentials, GcpRegistryCredentials) + assert params.credentials.type == "gcp" def test_accepts_azure_registry_credentials(self): params = CreateTemplateParams( @@ -77,4 +91,16 @@ def test_accepts_azure_registry_credentials(self): "azure_tenant_id": "tenant-id", }, ) - assert params.credentials["type"] == RegistryCredentialType.AZURE + assert isinstance(params.credentials, AzureRegistryCredentials) + assert params.credentials.type == "azure" + + def test_rejects_incomplete_basic_registry_credentials(self): + with pytest.raises(ValueError, match="password"): + CreateTemplateParams( + name="private-basic", + uri="registry.example.com/org/app:latest", + credentials={ + "type": RegistryCredentialType.BASIC, + "username": "my-user", + }, + ) diff --git a/tests/test_import.py b/tests/test_import.py index e3d14c6..10d6619 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -14,8 +14,11 @@ AsyncSnapshotsClient, AsyncSshClient, AsyncTemplatesClient, + AwsRegistryCredentials, AwsRegistryCredentialsDict, + AzureRegistryCredentials, AzureRegistryCredentialsDict, + BasicRegistryCredentials, BasicRegistryCredentialsDict, CreatePtySessionParams, CreateSandboxParams, @@ -24,6 +27,7 @@ DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME, DEFAULT_TEMPLATE_NAME, FilesystemClient, + GcpRegistryCredentials, GcpRegistryCredentialsDict, GitClient, Leap0Client, @@ -69,9 +73,13 @@ def test_service_client_imports() -> None: assert AsyncSshClient is not None assert AsyncTemplatesClient is not None assert AsyncPtyConnection is not None + assert BasicRegistryCredentials is not None assert BasicRegistryCredentialsDict is not None + assert AwsRegistryCredentials is not None assert AwsRegistryCredentialsDict is not None + assert GcpRegistryCredentials is not None assert GcpRegistryCredentialsDict is not None + assert AzureRegistryCredentials is not None assert AzureRegistryCredentialsDict is not None assert CreateSandboxParams is not None assert CreateSnapshotParams is not None