From e4a27e0327da45d97425923644a4db15cd4d52d6 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Mon, 30 Mar 2026 22:45:08 -0400 Subject: [PATCH 1/7] fix --- leap0/_types.py | 3 ++- leap0/client.py | 26 ++++++++++++++++--------- leap0/config.py | 18 ++++++++++++----- leap0/constants.py | 1 + leap0/desktop.py | 7 ++++--- leap0/models.py | 2 ++ tests/test_config.py | 46 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_models.py | 3 +++ 8 files changed, 88 insertions(+), 18 deletions(-) diff --git a/leap0/_types.py b/leap0/_types.py index adba619..c4d75b5 100644 --- a/leap0/_types.py +++ b/leap0/_types.py @@ -16,7 +16,7 @@ class SandboxCreateResponseDict(TypedDict): state: SandboxState auto_pause: bool created_at: str - network_policy: NotRequired[NetworkPolicyDict | None] + network_policy: NetworkPolicyDict | None class SandboxStatusResponseDict(TypedDict): @@ -188,6 +188,7 @@ class ImageConfigDict(TypedDict, total=False): entrypoint: list[str] | None cmd: list[str] | None working_dir: str | None + user: str env: dict[str, Any] | None diff --git a/leap0/client.py b/leap0/client.py index ed279bf..d897a43 100644 --- a/leap0/client.py +++ b/leap0/client.py @@ -10,6 +10,7 @@ from .config import DEFAULT_CLIENT_TIMEOUT, Leap0Config from .constants import ( DEFAULT_BASE_URL, + DEFAULT_DESKTOP_TEMPLATE_NAME, DEFAULT_MEMORY_MIB, DEFAULT_SANDBOX_DOMAIN, DEFAULT_TEMPLATE_NAME, @@ -36,10 +37,14 @@ class Leap0Client: connection is closed on exit. Args: - api_key: API key for authentication. Falls back to the LEAP0_API_KEY + api_key: API key for authentication. Falls back to the ``LEAP0_API_KEY`` environment variable when not provided. - base_url: Base URL of the Leap0 control-plane API. - sandbox_domain: Domain suffix used to build per-sandbox URLs. + base_url: Base URL of the Leap0 control-plane API. Falls back to the + ``LEAP0_BASE_URL`` environment variable, then to + ``https://api.leap0.dev``. + sandbox_domain: Domain suffix used to build per-sandbox URLs. Falls + back to the ``LEAP0_SANDBOX_DOMAIN`` environment variable, then to + ``sandbox.leap0.dev``. timeout: Default HTTP timeout in seconds. auth_header: Name of the header used to send the API key. bearer: When True, the key is sent with a ``Bearer`` prefix. @@ -47,6 +52,7 @@ class Leap0Client: DEFAULT_BASE_URL = DEFAULT_BASE_URL DEFAULT_SANDBOX_DOMAIN = DEFAULT_SANDBOX_DOMAIN DEFAULT_TEMPLATE_NAME = DEFAULT_TEMPLATE_NAME + DEFAULT_DESKTOP_TEMPLATE_NAME = DEFAULT_DESKTOP_TEMPLATE_NAME DEFAULT_VCPU = DEFAULT_VCPU DEFAULT_MEMORY_MIB = DEFAULT_MEMORY_MIB DEFAULT_TIMEOUT_MIN = DEFAULT_TIMEOUT_MIN @@ -55,8 +61,8 @@ def __init__( self, *, api_key: str | None = None, - base_url: str = DEFAULT_BASE_URL, - sandbox_domain: str | None = DEFAULT_SANDBOX_DOMAIN, + base_url: str | None = None, + sandbox_domain: str | None = None, timeout: float = DEFAULT_CLIENT_TIMEOUT, auth_header: str = "authorization", bearer: bool = True, @@ -64,14 +70,16 @@ def __init__( resolved_api_key = api_key or os.environ.get("LEAP0_API_KEY") if not resolved_api_key: raise ValueError("api_key is required or set LEAP0_API_KEY") + resolved_base_url = base_url or os.environ.get("LEAP0_BASE_URL") or DEFAULT_BASE_URL + resolved_sandbox_domain = sandbox_domain or os.environ.get("LEAP0_SANDBOX_DOMAIN") or DEFAULT_SANDBOX_DOMAIN self._transport = Transport( api_key=resolved_api_key, - base_url=base_url, + base_url=resolved_base_url, timeout=timeout, auth_header=auth_header, bearer=bearer, ) - self.sandboxes = SandboxesClient(self._transport, sandbox_domain=sandbox_domain) + self.sandboxes = SandboxesClient(self._transport, sandbox_domain=resolved_sandbox_domain) self.snapshots = SnapshotsClient(self._transport) self.templates = TemplatesClient(self._transport) self.filesystem = FilesystemClient(self._transport) @@ -80,8 +88,8 @@ def __init__( self.pty = PtyClient(self._transport) self.lsp = LspClient(self._transport) self.ssh = SshClient(self._transport) - self.code_interpreter = CodeInterpreterClient(self._transport, sandbox_domain=sandbox_domain) - self.desktop = DesktopClient(self._transport, sandbox_domain=sandbox_domain) + self.code_interpreter = CodeInterpreterClient(self._transport, sandbox_domain=resolved_sandbox_domain) + self.desktop = DesktopClient(self._transport, sandbox_domain=resolved_sandbox_domain) def close(self) -> None: self._transport.close() diff --git a/leap0/config.py b/leap0/config.py index 199a0ae..0fa0edf 100644 --- a/leap0/config.py +++ b/leap0/config.py @@ -14,17 +14,21 @@ class Leap0Config: """Configuration for a Leap0 client. Args: - api_key: API key for authentication. Falls back to the LEAP0_API_KEY + api_key: API key for authentication. Falls back to the ``LEAP0_API_KEY`` environment variable when ``None``. - base_url: Base URL of the Leap0 control-plane API. - sandbox_domain: Domain suffix used to build per-sandbox URLs. + base_url: Base URL of the Leap0 control-plane API. Falls back to the + ``LEAP0_BASE_URL`` environment variable, then to + ``https://api.leap0.dev``. + sandbox_domain: Domain suffix used to build per-sandbox URLs. Falls + back to the ``LEAP0_SANDBOX_DOMAIN`` environment variable, then to + ``sandbox.leap0.dev``. timeout: Default HTTP timeout in seconds. auth_header: Name of the header used to send the API key. bearer: When True, the key is sent with a ``Bearer`` prefix. """ api_key: str | None = None - base_url: str = DEFAULT_BASE_URL - sandbox_domain: str | None = DEFAULT_SANDBOX_DOMAIN + base_url: str | None = None + sandbox_domain: str | None = None timeout: float = DEFAULT_CLIENT_TIMEOUT auth_header: str = "authorization" bearer: bool = True @@ -34,3 +38,7 @@ def __post_init__(self) -> None: self.api_key = os.environ.get("LEAP0_API_KEY") if not self.api_key: raise ValueError("api_key is required or set LEAP0_API_KEY") + if self.base_url is None: + self.base_url = os.environ.get("LEAP0_BASE_URL") or DEFAULT_BASE_URL + if self.sandbox_domain is None: + self.sandbox_domain = os.environ.get("LEAP0_SANDBOX_DOMAIN") or DEFAULT_SANDBOX_DOMAIN diff --git a/leap0/constants.py b/leap0/constants.py index 676772b..79f8a28 100644 --- a/leap0/constants.py +++ b/leap0/constants.py @@ -1,6 +1,7 @@ DEFAULT_BASE_URL = "https://api.leap0.dev" DEFAULT_SANDBOX_DOMAIN = "sandbox.leap0.dev" DEFAULT_TEMPLATE_NAME = "system/code-interpreter:v0.1.0" +DEFAULT_DESKTOP_TEMPLATE_NAME = "system/desktop:v0.1.0" DEFAULT_VCPU = 1 DEFAULT_MEMORY_MIB = 1024 DEFAULT_TIMEOUT_MIN = 5 diff --git a/leap0/desktop.py b/leap0/desktop.py index cf8b374..a4b11a5 100644 --- a/leap0/desktop.py +++ b/leap0/desktop.py @@ -40,9 +40,10 @@ class DesktopClient: """Control a graphical Linux desktop inside a sandbox. - Requires a sandbox created with the ``system/desktop:v0.1.0`` template. - Provides display info, screenshots, mouse/keyboard input, and screen - recording. + Requires a sandbox created with the + :data:`~leap0.constants.DEFAULT_DESKTOP_TEMPLATE_NAME` + (``system/desktop:v0.1.0``) template. Provides display info, screenshots, + mouse/keyboard input, and screen recording. """ def __init__(self, transport: Transport, *, sandbox_domain: str | None = None): diff --git a/leap0/models.py b/leap0/models.py index 329d7e7..38133c8 100644 --- a/leap0/models.py +++ b/leap0/models.py @@ -392,6 +392,7 @@ class ImageConfig: entrypoint: list[str] = field(default_factory=list) cmd: list[str] = field(default_factory=list) working_dir: str | None = None + user: str = "" env: dict[str, Any] | None = None @classmethod @@ -400,6 +401,7 @@ def from_dict(cls, data: ImageConfigDict) -> ImageConfig: entrypoint=data.get("entrypoint") or [], cmd=data.get("cmd") or [], working_dir=data.get("working_dir"), + user=data.get("user", ""), env=data.get("env"), ) diff --git a/tests/test_config.py b/tests/test_config.py index 3123962..0f6eecb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -39,6 +39,26 @@ def test_default_values(self): assert cfg.auth_header == "authorization" assert cfg.bearer is True + def test_base_url_from_env(self): + with patch.dict(os.environ, {"LEAP0_API_KEY": "key", "LEAP0_BASE_URL": "https://api.custom.dev"}): + cfg = Leap0Config() + assert cfg.base_url == "https://api.custom.dev" + + def test_sandbox_domain_from_env(self): + with patch.dict(os.environ, {"LEAP0_API_KEY": "key", "LEAP0_SANDBOX_DOMAIN": "sandbox.custom.dev"}): + cfg = Leap0Config() + assert cfg.sandbox_domain == "sandbox.custom.dev" + + def test_explicit_base_url_overrides_env(self): + with patch.dict(os.environ, {"LEAP0_BASE_URL": "https://api.env.dev"}): + cfg = Leap0Config(api_key="key", base_url="https://api.explicit.dev") + assert cfg.base_url == "https://api.explicit.dev" + + def test_explicit_sandbox_domain_overrides_env(self): + with patch.dict(os.environ, {"LEAP0_SANDBOX_DOMAIN": "sandbox.env.dev"}): + cfg = Leap0Config(api_key="key", sandbox_domain="sandbox.explicit.dev") + assert cfg.sandbox_domain == "sandbox.explicit.dev" + class TestLeap0Client: def test_raises_when_no_key(self): @@ -70,3 +90,29 @@ def test_api_key_from_env(self): with patch.dict(os.environ, {"LEAP0_API_KEY": "env-key"}): client = Leap0Client() client.close() + + def test_base_url_from_env(self): + with patch.dict(os.environ, {"LEAP0_BASE_URL": "https://api.custom.dev"}): + client = Leap0Client(api_key="test-key") + assert client._transport.base_url == "https://api.custom.dev" + client.close() + + def test_sandbox_domain_from_env(self): + with patch.dict(os.environ, {"LEAP0_SANDBOX_DOMAIN": "sandbox.custom.dev"}): + client = Leap0Client(api_key="test-key") + assert client.sandboxes._sandbox_domain == "sandbox.custom.dev" + assert client.desktop._sandbox_domain == "sandbox.custom.dev" + assert client.code_interpreter._sandbox_domain == "sandbox.custom.dev" + client.close() + + def test_explicit_base_url_overrides_env(self): + with patch.dict(os.environ, {"LEAP0_BASE_URL": "https://api.env.dev"}): + client = Leap0Client(api_key="test-key", base_url="https://api.explicit.dev") + assert client._transport.base_url == "https://api.explicit.dev" + client.close() + + def test_explicit_sandbox_domain_overrides_env(self): + with patch.dict(os.environ, {"LEAP0_SANDBOX_DOMAIN": "sandbox.env.dev"}): + client = Leap0Client(api_key="test-key", sandbox_domain="sandbox.explicit.dev") + assert client.sandboxes._sandbox_domain == "sandbox.explicit.dev" + client.close() diff --git a/tests/test_models.py b/tests/test_models.py index 9badcd8..945658f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -430,12 +430,14 @@ def test_from_dict_full(self): "entrypoint": ["/bin/sh"], "cmd": ["-c", "echo hi"], "working_dir": "/workspace", + "user": "appuser", "env": {"PATH": "/usr/bin"}, } c = ImageConfig.from_dict(data) # type: ignore[arg-type] assert c.entrypoint == ["/bin/sh"] assert c.cmd == ["-c", "echo hi"] assert c.working_dir == "/workspace" + assert c.user == "appuser" assert c.env == {"PATH": "/usr/bin"} def test_null_lists(self): @@ -443,6 +445,7 @@ def test_null_lists(self): c = ImageConfig.from_dict(data) # type: ignore[arg-type] assert c.entrypoint == [] assert c.cmd == [] + assert c.user == "" # Template From 7e227272e2dc8e3527fe42370b597d368530b2cc Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Tue, 31 Mar 2026 13:57:57 -0400 Subject: [PATCH 2/7] folder stucture and errors --- examples/desktop.py | 6 +- leap0/__init__.py | 170 +++--- leap0/_transport.py | 14 +- leap0/_types.py | 349 ----------- leap0/_utils.py | 82 --- leap0/_utils/__init__.py | 1 + leap0/_utils/encoding.py | 19 + leap0/_utils/errors.py | 61 ++ leap0/_utils/stream.py | 39 ++ leap0/_utils/url.py | 27 + leap0/client.py | 5 +- leap0/code_interpreter.py | 27 +- leap0/common/__init__.py | 1 + leap0/common/code_interpreter.py | 212 +++++++ leap0/{ => common}/config.py | 11 +- leap0/common/desktop.py | 274 +++++++++ leap0/common/errors.py | 117 ++++ leap0/common/filesystem.py | 178 ++++++ leap0/common/git.py | 38 ++ leap0/common/lsp.py | 17 + leap0/common/process.py | 19 + leap0/common/pty.py | 71 +++ leap0/common/sandbox.py | 108 ++++ leap0/common/snapshot.py | 58 ++ leap0/common/ssh.py | 55 ++ leap0/common/template.py | 78 +++ leap0/constants.py | 7 - leap0/desktop.py | 73 ++- leap0/exceptions.py | 28 - leap0/filesystem.py | 39 +- leap0/git.py | 25 +- leap0/lsp.py | 17 +- leap0/models.py | 770 ------------------------ leap0/process.py | 6 +- leap0/pty.py | 15 +- leap0/sandboxes.py | 15 +- leap0/snapshots.py | 9 +- leap0/ssh.py | 8 +- leap0/templates.py | 12 +- tests/__init__.py | 0 tests/_utils/__init__.py | 0 tests/_utils/test_encoding.py | 16 + tests/_utils/test_stream.py | 46 ++ tests/_utils/test_url.py | 54 ++ tests/common/__init__.py | 0 tests/common/test_code_interpreter.py | 119 ++++ tests/common/test_config.py | 60 ++ tests/common/test_desktop.py | 89 +++ tests/common/test_errors.py | 59 ++ tests/common/test_filesystem.py | 85 +++ tests/common/test_git.py | 27 + tests/common/test_lsp.py | 11 + tests/common/test_process.py | 10 + tests/common/test_pty.py | 21 + tests/common/test_sandbox.py | 46 ++ tests/common/test_snapshot.py | 28 + tests/common/test_ssh.py | 21 + tests/common/test_template.py | 29 + tests/conftest.py | 20 + tests/test_clients.py | 360 ----------- tests/test_config.py | 118 ---- tests/test_filesystem.py | 48 ++ tests/test_git.py | 17 + tests/test_models.py | 824 -------------------------- tests/test_process.py | 12 + tests/test_sandboxes.py | 43 ++ tests/test_snapshots.py | 28 + tests/test_ssh.py | 19 + tests/test_templates.py | 29 + tests/test_transport.py | 124 ++-- tests/test_utils.py | 174 ------ 71 files changed, 2662 insertions(+), 2936 deletions(-) delete mode 100644 leap0/_types.py delete mode 100644 leap0/_utils.py create mode 100644 leap0/_utils/__init__.py create mode 100644 leap0/_utils/encoding.py create mode 100644 leap0/_utils/errors.py create mode 100644 leap0/_utils/stream.py create mode 100644 leap0/_utils/url.py create mode 100644 leap0/common/__init__.py create mode 100644 leap0/common/code_interpreter.py rename leap0/{ => common}/config.py (85%) create mode 100644 leap0/common/desktop.py create mode 100644 leap0/common/errors.py create mode 100644 leap0/common/filesystem.py create mode 100644 leap0/common/git.py create mode 100644 leap0/common/lsp.py create mode 100644 leap0/common/process.py create mode 100644 leap0/common/pty.py create mode 100644 leap0/common/sandbox.py create mode 100644 leap0/common/snapshot.py create mode 100644 leap0/common/ssh.py create mode 100644 leap0/common/template.py delete mode 100644 leap0/constants.py delete mode 100644 leap0/exceptions.py delete mode 100644 leap0/models.py create mode 100644 tests/__init__.py create mode 100644 tests/_utils/__init__.py create mode 100644 tests/_utils/test_encoding.py create mode 100644 tests/_utils/test_stream.py create mode 100644 tests/_utils/test_url.py create mode 100644 tests/common/__init__.py create mode 100644 tests/common/test_code_interpreter.py create mode 100644 tests/common/test_config.py create mode 100644 tests/common/test_desktop.py create mode 100644 tests/common/test_errors.py create mode 100644 tests/common/test_filesystem.py create mode 100644 tests/common/test_git.py create mode 100644 tests/common/test_lsp.py create mode 100644 tests/common/test_process.py create mode 100644 tests/common/test_pty.py create mode 100644 tests/common/test_sandbox.py create mode 100644 tests/common/test_snapshot.py create mode 100644 tests/common/test_ssh.py create mode 100644 tests/common/test_template.py create mode 100644 tests/conftest.py delete mode 100644 tests/test_clients.py delete mode 100644 tests/test_config.py create mode 100644 tests/test_filesystem.py create mode 100644 tests/test_git.py delete mode 100644 tests/test_models.py create mode 100644 tests/test_process.py create mode 100644 tests/test_sandboxes.py create mode 100644 tests/test_snapshots.py create mode 100644 tests/test_ssh.py create mode 100644 tests/test_templates.py delete mode 100644 tests/test_utils.py diff --git a/examples/desktop.py b/examples/desktop.py index 14f2a71..7cef550 100644 --- a/examples/desktop.py +++ b/examples/desktop.py @@ -6,8 +6,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from leap0 import Leap0, Leap0APIError, Leap0Client, Leap0Config, Leap0Error -from leap0.models import SandboxRef +from leap0 import Leap0, Leap0Client, Leap0Config, Leap0Error +from leap0.common.sandbox import SandboxRef def wait_for_desktop(client: Leap0Client, sandbox: SandboxRef, *, timeout_seconds: float = 60.0) -> None: @@ -17,7 +17,7 @@ def wait_for_desktop(client: Leap0Client, sandbox: SandboxRef, *, timeout_second if sandbox_status.state == "running": try: health = client.desktop.health(sandbox) - except Leap0APIError: + except Leap0Error: pass else: if health.ok and health.state == "ready": diff --git a/leap0/__init__.py b/leap0/__init__.py index c2506b6..c834af6 100644 --- a/leap0/__init__.py +++ b/leap0/__init__.py @@ -1,85 +1,89 @@ from .client import Leap0, Leap0Client -from .config import Leap0Config -from .exceptions import Leap0APIError, Leap0Error, Leap0WebSocketError -from .models import ( - CodeContext, - CodeExecutionError, - CodeExecutionOutput, - CodeExecutionResult, - DesktopDisplayInfo, - DesktopHealth, - DesktopPointerPosition, - DesktopProcessErrors, - DesktopProcessLogs, - DesktopProcessRestart, - DesktopProcessStatus, - DesktopProcessStatusList, - DesktopRecordingStatus, - DesktopRecordingSummary, - DesktopWindow, - EditFileResult, - EditResult, - ExecutionLogs, - FileEdit, - FileInfo, - GitCommitResult, - GitResult, - ImageConfig, - LsResult, - LspResponse, - ProcessResult, - PtyConnection, - PtySession, - Sandbox, - SandboxStatus, - SearchMatch, - Snapshot, - SshAccess, - SshValidation, - StreamEvent, - Template, - TreeEntry, - TreeResult, +from .common.config import Leap0Config +from .common.errors import ( + Leap0ConflictError, + Leap0Error, + Leap0NotFoundError, + Leap0PermissionError, + Leap0RateLimitError, + Leap0TimeoutError, + Leap0WebSocketError, ) -from ._types import ( - CodeContextDict, - CodeExecutionOutputDict, - CodeExecutionResultDict, - DesktopDisplayInfoDict, - DesktopHealthDict, - DesktopPointerPositionDict, - DesktopProcessErrorsDict, - DesktopProcessLogsDict, - DesktopProcessRestartDict, - DesktopProcessStatusDict, - DesktopProcessStatusListDict, - DesktopRecordingStatusDict, - DesktopRecordingSummaryDict, - DesktopWindowDict, - EditFileResponseDict, - EditResultDict, - ExecutionErrorDict, - ExecutionLogsDict, - FileInfoDict, - GitCommitResponseDict, - GitResultDict, - ImageConfigDict, - LsResponseDict, - LspSuccessResponseDict, - ProcessResultDict, - PtySessionInfoDict, - RegistryCredentialsDict, - SandboxCreateResponseDict, - SandboxState, - SandboxStatusResponseDict, - SearchMatchDict, - SnapshotCreateResponseDict, - SshAccessValidationDict, - SshCreateAccessDict, - StreamEventDict, - TreeEntryDict, - TreeResponseDict, - UploadTemplateResponseDict, +from .common.sandbox import Sandbox, SandboxStatus +from .common.snapshot import Snapshot +from .common.filesystem import ( + EditFileResult, EditResult, FileEdit, FileInfo, LsResult, SearchMatch, TreeEntry, TreeResult, +) +from .common.git import GitCommitResult, GitResult +from .common.process import ProcessResult +from .common.pty import PtyConnection, PtySession +from .common.lsp import LspResponse +from .common.ssh import SshAccess, SshValidation +from .common.template import ImageConfig, Template +from .common.code_interpreter import ( + CodeContext, CodeExecutionError, CodeExecutionOutput, CodeExecutionResult, ExecutionLogs, StreamEvent, +) +from .common.desktop import ( + DesktopDisplayInfo, DesktopHealth, DesktopPointerPosition, DesktopProcessErrors, + DesktopProcessLogs, DesktopProcessRestart, DesktopProcessStatus, DesktopProcessStatusList, + DesktopRecordingStatus, DesktopRecordingSummary, DesktopWindow, +) + +# TypedDicts +from .common.sandbox import ( + NetworkPolicyDict as NetworkPolicyDict, + SandboxCreateResponseDict as SandboxCreateResponseDict, + SandboxState as SandboxState, + SandboxStatusResponseDict as SandboxStatusResponseDict, +) +from .common.snapshot import SnapshotCreateResponseDict as SnapshotCreateResponseDict +from .common.filesystem import ( + EditFileResponseDict as EditFileResponseDict, + EditResultDict as EditResultDict, + FileInfoDict as FileInfoDict, + GlobResponseDict as GlobResponseDict, + GrepResponseDict as GrepResponseDict, + LsResponseDict as LsResponseDict, + SearchMatchDict as SearchMatchDict, + TreeEntryDict as TreeEntryDict, + TreeResponseDict as TreeResponseDict, +) +from .common.git import ( + GitCommitResponseDict as GitCommitResponseDict, + GitResultDict as GitResultDict, +) +from .common.process import ProcessResultDict as ProcessResultDict +from .common.pty import PtySessionInfoDict as PtySessionInfoDict +from .common.lsp import LspSuccessResponseDict as LspSuccessResponseDict +from .common.ssh import ( + SshAccessValidationDict as SshAccessValidationDict, + SshCreateAccessDict as SshCreateAccessDict, +) +from .common.template import ( + ImageConfigDict as ImageConfigDict, + RegistryCredentialsDict as RegistryCredentialsDict, + UploadTemplateResponseDict as UploadTemplateResponseDict, +) +from .common.code_interpreter import ( + CodeContextDict as CodeContextDict, + CodeExecutionOutputDict as CodeExecutionOutputDict, + CodeExecutionResultDict as CodeExecutionResultDict, + ExecutionErrorDict as ExecutionErrorDict, + ExecutionLogsDict as ExecutionLogsDict, + StreamEventDict as StreamEventDict, +) +from .common.desktop import ( + DesktopDisplayInfoDict as DesktopDisplayInfoDict, + DesktopHealthDict as DesktopHealthDict, + DesktopPointerPositionDict as DesktopPointerPositionDict, + DesktopProcessErrorsDict as DesktopProcessErrorsDict, + DesktopProcessLogsDict as DesktopProcessLogsDict, + DesktopProcessRestartDict as DesktopProcessRestartDict, + DesktopProcessStatusDict as DesktopProcessStatusDict, + DesktopProcessStatusListDict as DesktopProcessStatusListDict, + DesktopRecordingStatusDict as DesktopRecordingStatusDict, + DesktopRecordingSummaryDict as DesktopRecordingSummaryDict, + DesktopWindowDict as DesktopWindowDict, ) __all__ = [ @@ -107,10 +111,14 @@ "GitResult", "ImageConfig", "Leap0", - "Leap0APIError", + "Leap0ConflictError", "Leap0Config", "Leap0Client", "Leap0Error", + "Leap0NotFoundError", + "Leap0PermissionError", + "Leap0RateLimitError", + "Leap0TimeoutError", "Leap0WebSocketError", "LsResult", "LspResponse", diff --git a/leap0/_transport.py b/leap0/_transport.py index 05571f7..a8532c6 100644 --- a/leap0/_transport.py +++ b/leap0/_transport.py @@ -4,8 +4,8 @@ import httpx -from .config import DEFAULT_CLIENT_TIMEOUT -from .exceptions import Leap0APIError +from .common.config import DEFAULT_CLIENT_TIMEOUT +from .common.errors import raise_api_error class Transport: @@ -51,7 +51,12 @@ def _target_url(self, target: str) -> str: def _check_response(self, response: httpx.Response, method: str, target: str, expected_status: int | tuple[int, ...]) -> httpx.Response: expected = self._expected(expected_status) if response.status_code not in expected: - raise Leap0APIError(response.status_code, f"Request failed: {method} {target}", body=response.text) + raise_api_error( + response.status_code, + f"Request failed: {method} {target}", + body=response.text, + headers=dict(response.headers), + ) return response def _request( @@ -100,8 +105,9 @@ def _stream( response = self._client.send(request, stream=True) if response.status_code >= 400: body = response.read().decode("utf-8", errors="replace") + hdrs = dict(response.headers) response.close() - raise Leap0APIError(response.status_code, f"Request failed: {method} {target}", body=body) + raise_api_error(response.status_code, f"Request failed: {method} {target}", body=body, headers=hdrs) return response def request( diff --git a/leap0/_types.py b/leap0/_types.py deleted file mode 100644 index c4d75b5..0000000 --- a/leap0/_types.py +++ /dev/null @@ -1,349 +0,0 @@ -from __future__ import annotations - -from typing import Any, Literal, TypedDict - -from typing_extensions import NotRequired, Required - -SandboxState = Literal["starting", "running", "paused", "unpausing", "terminating"] - - -class SandboxCreateResponseDict(TypedDict): - id: str - template_id: str - vcpu: int - memory_mib: int - disk_mib: int - state: SandboxState - auto_pause: bool - created_at: str - network_policy: NetworkPolicyDict | None - - -class SandboxStatusResponseDict(TypedDict): - id: str - template_id: str - vcpu: int - memory_mib: int - disk_mib: int - state: SandboxState - auto_pause: bool - created_at: str - - -class TransformRuleDict(TypedDict, total=False): - domain: Required[str] - inject_headers: NotRequired[dict[str, str]] - strip_headers: NotRequired[list[str]] - - -class NetworkPolicyDict(TypedDict, total=False): - mode: Required[Literal["allow-all", "deny-all", "custom"]] - allow_domains: NotRequired[list[str]] - allow_cidrs: NotRequired[list[str]] - transforms: NotRequired[list[TransformRuleDict]] - - -class SnapshotCreateResponseDict(TypedDict): - snapshot_id: str - name: str - template_id: str - vcpu: int - memory_mib: int - disk_mib: int - created_at: str - network_policy: NetworkPolicyDict | None - - -class FileInfoDict(TypedDict, total=False): - name: str - path: str - is_dir: bool - size: int - mode: str - mtime: int - owner: str - group: str - is_symlink: bool - link_target: str - - -class LsResponseDict(TypedDict): - items: list[FileInfoDict] - - -class GlobResponseDict(TypedDict): - items: list[str] - - -class SearchMatchDict(TypedDict, total=False): - path: str - line: int - content: str - - -class GrepResponseDict(TypedDict): - items: list[SearchMatchDict] - - -class EditFileResponseDict(TypedDict, total=False): - diff: str - replacements: int - - -class EditResultDict(TypedDict, total=False): - file: str - success: bool - error: str - - -class EditFilesResponseDict(TypedDict): - items: list[EditResultDict] - - -class ExistsResponseDict(TypedDict): - exists: bool - - -class TreeEntryDict(TypedDict, total=False): - name: str - type: str - children: list[TreeEntryDict] - - -class TreeResponseDict(TypedDict): - items: list[TreeEntryDict] - - -class GitResultDict(TypedDict, total=False): - output: str - exit_code: int - - -class GitCommitResponseDict(TypedDict, total=False): - sha: str | None - result: GitResultDict | None - - -class ProcessResultDict(TypedDict, total=False): - exit_code: int - result: str - - -class SshCreateAccessDict(TypedDict, total=False): - id: str - sandbox_id: str - password: str - expires_at: str - created_at: str - updated_at: str - ssh_command: str - - -class SshAccessValidationDict(TypedDict, total=False): - valid: bool - sandbox_id: str - - -class PtyCreateResponseDict(TypedDict, total=False): - session_id: str - - -class PtySessionInfoDict(TypedDict, total=False): - id: str - session_id: str - cwd: str - envs: dict[str, str] - cols: int - rows: int - created_at: str - active: bool - lazy_start: bool - - -class PtyListResponseDict(TypedDict, total=False): - items: list[PtySessionInfoDict] - - -class LspSuccessResponseDict(TypedDict, total=False): - success: bool - - -RegistryCredentialType = Literal["basic", "aws", "gcp", "azure"] - - -class RegistryCredentialsDict(TypedDict, total=False): - type: RegistryCredentialType - username: str - password: str - aws_access_key_id: str - aws_secret_access_key: str - aws_region: str - gcp_service_account_json: str - azure_client_id: str - azure_client_secret: str - azure_tenant_id: str - - -class ImageConfigDict(TypedDict, total=False): - entrypoint: list[str] | None - cmd: list[str] | None - working_dir: str | None - user: str - env: dict[str, Any] | None - - -class UploadTemplateResponseDict(TypedDict): - id: str - name: str - digest: str - image_config: ImageConfigDict | None - is_system: bool - created_at: str - - -LanguageLiteral = Literal["python", "typescript"] - - -class CodeExecutionOutputDict(TypedDict, total=False): - is_primary: bool - is_main_result: bool - text: str | None - html: str | None - markdown: str | None - svg: str | None - png: str | None - jpeg: str | None - pdf: str | None - latex: str | None - json: dict[str, Any] | None - javascript: str | None - extra: dict[str, Any] | None - - -class ExecutionErrorDict(TypedDict, total=False): - name: str - value: str - traceback: str - - -class ExecutionLogsDict(TypedDict, total=False): - stdout: list[str] - stderr: list[str] - - -class CodeExecutionResultDict(TypedDict, total=False): - items: list[CodeExecutionOutputDict] - logs: ExecutionLogsDict - error: ExecutionErrorDict | None - execution_count: int | None - - -StreamEventTypeLiteral = Literal["stdout", "stderr", "exit", "error"] - - -class StreamEventDict(TypedDict, total=False): - type: int | str - data: str - code: int | None - - -class CodeContextDict(TypedDict, total=False): - id: str - context_id: str - language: int | str - cwd: str - - -class ListContextsResponseDict(TypedDict): - items: list[CodeContextDict] - - -class DesktopDisplayInfoDict(TypedDict, total=False): - display: str - width: int - height: int - - -class DesktopWindowDict(TypedDict, total=False): - id: str - desktop: int - pid: int - x: int - y: int - width: int - height: int - class_: str - host: str - title: str - focused: bool - - -class DesktopWindowsDict(TypedDict): - items: list[DesktopWindowDict] - - -class DesktopPointerPositionDict(TypedDict, total=False): - x: int - y: int - - -class DesktopRecordingStatusDict(TypedDict, total=False): - id: str - active: bool - path: str - started_at: str - stopped_at: str - download: str - mime_type: str - file_name: str - display: str - resolution: str - - -class DesktopRecordingSummaryDict(TypedDict, total=False): - id: str - file_name: str - path: str - download: str - mime_type: str - size_bytes: int - created_at: str - active: bool - - -DesktopHealthState = Literal["starting", "ready", "degraded"] - - -class DesktopHealthDict(TypedDict, total=False): - ok: bool - state: DesktopHealthState - - -class DesktopProcessStatusDict(TypedDict, total=False): - name: str - running: bool - pid: int - stdout_log: str - stderr_log: str - - -class DesktopProcessStatusListDict(TypedDict, total=False): - status: str - items: list[DesktopProcessStatusDict] - running: int - total: int - - -class DesktopProcessRestartDict(TypedDict, total=False): - message: str - status: DesktopProcessStatusDict - - -class DesktopProcessLogsDict(TypedDict, total=False): - process: str - logs: str - - -class DesktopProcessErrorsDict(TypedDict, total=False): - process: str - errors: str diff --git a/leap0/_utils.py b/leap0/_utils.py deleted file mode 100644 index e304773..0000000 --- a/leap0/_utils.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -import base64 -import json -from typing import Any, Iterable, Iterator - - -def b64encode_bytes(value: bytes) -> str: - return base64.b64encode(value).decode("ascii") - - -def b64encode_text(value: str, encoding: str = "utf-8") -> str: - return b64encode_bytes(value.encode(encoding)) - - -def b64decode_text(value: str, encoding: str = "utf-8") -> str: - return base64.b64decode(value).decode(encoding) - - -def b64decode_bytes(value: str) -> bytes: - return base64.b64decode(value) - - -def ensure_leading_slash(value: str) -> str: - return value if value.startswith("/") else f"/{value}" - - -def sandbox_base_url(sandbox_id: str, sandbox_domain: str | None, *, port: int | None = None) -> str: - if not sandbox_domain: - raise ValueError("sandbox_domain is required for sandbox host operations") - subdomain = f"{sandbox_id}-{port}" if port is not None else sandbox_id - host = f"{subdomain}.{sandbox_domain.strip('/')}" - return f"https://{host}" - - -def websocket_url_from_http(url: str) -> str: - if url.startswith("https://"): - return url.replace("https://", "wss://", 1) - if url.startswith("http://"): - return url.replace("http://", "ws://", 1) - return url - - -def file_uri(path: str) -> str: - """Build a file:// URI for a sandbox-side absolute path.""" - clean = path if path.startswith("/") else f"/{path}" - return f"file://{clean}" - - -def iter_ndjson(lines: Iterable[str]) -> Iterator[dict[str, Any]]: - for line in lines: - raw = line.strip() - if raw: - yield json.loads(raw) - - -def _sse_data_value(raw: str) -> str: - """Extract the value from a 'data:' SSE field, stripping at most one leading space per spec.""" - value = raw[5:] - if value.startswith(" "): - value = value[1:] - return value - - -def iter_sse_events(lines: Iterable[str]) -> Iterator[dict[str, Any]]: - buffer: list[str] = [] - for line in lines: - stripped = line.rstrip("\r") - if stripped == "": - if buffer: - data_lines = [_sse_data_value(item) for item in buffer if item.startswith("data:")] - if data_lines: - yield json.loads("\n".join(data_lines)) - buffer.clear() - continue - if stripped.startswith(":"): - continue - buffer.append(stripped) - if buffer: - data_lines = [_sse_data_value(item) for item in buffer if item.startswith("data:")] - if data_lines: - yield json.loads("\n".join(data_lines)) diff --git a/leap0/_utils/__init__.py b/leap0/_utils/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/leap0/_utils/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/leap0/_utils/encoding.py b/leap0/_utils/encoding.py new file mode 100644 index 0000000..27e35f9 --- /dev/null +++ b/leap0/_utils/encoding.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import base64 + + +def b64encode_bytes(value: bytes) -> str: + return base64.b64encode(value).decode("ascii") + + +def b64encode_text(value: str, encoding: str = "utf-8") -> str: + return b64encode_bytes(value.encode(encoding)) + + +def b64decode_text(value: str, encoding: str = "utf-8") -> str: + return base64.b64decode(value).decode(encoding) + + +def b64decode_bytes(value: str) -> bytes: + return base64.b64decode(value) diff --git a/leap0/_utils/errors.py b/leap0/_utils/errors.py new file mode 100644 index 0000000..8fa7aab --- /dev/null +++ b/leap0/_utils/errors.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import functools +from typing import Any, Callable, TypeVar + +from ..common.errors import Leap0Error, Leap0TimeoutError + +F = TypeVar("F", bound=Callable[..., Any]) + + +# Error interception decorator +def intercept_errors(message_prefix: str = "") -> Callable[[F], F]: + """Decorator that catches all exceptions and normalises them into + ``Leap0Error`` subclasses with a human-readable *message_prefix*. + + This wraps: + - ``Leap0Error`` -- re-raised with the prefix prepended. + - ``httpx.TimeoutException`` -- converted to ``Leap0TimeoutError``. + - ``httpx.ConnectError`` / ``httpx.NetworkError`` -- converted to ``Leap0Error``. + - Any other ``Exception`` -- wrapped in ``Leap0Error``. + """ + + def decorator(fn: F) -> F: + @functools.wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return fn(*args, **kwargs) + except Leap0Error as exc: + if message_prefix and not exc.message.startswith(message_prefix): + exc.message = f"{message_prefix}{exc.message}" + detail = exc.message + if exc.status_code is not None: + detail = f"{exc.status_code} {detail}" + if exc.error_message: + detail = f"{detail}: {exc.error_message}" + elif exc.body: + detail = f"{detail}: {exc.body}" + exc.args = (detail,) + raise + except Exception as exc: + _raise_wrapped(message_prefix, exc) + + return wrapper # type: ignore[return-value] + + return decorator + + +def _raise_wrapped(prefix: str, exc: Exception) -> None: + """Convert a non-SDK exception into the appropriate ``Leap0Error``.""" + try: + import httpx as _httpx + except ImportError: # pragma: no cover + _httpx = None # type: ignore[assignment] + + if _httpx is not None: + if isinstance(exc, _httpx.TimeoutException): + raise Leap0TimeoutError(f"{prefix}{exc}") from exc + if isinstance(exc, (_httpx.ConnectError, _httpx.NetworkError)): + raise Leap0Error(f"{prefix}{exc}") from exc + + raise Leap0Error(f"{prefix}{exc}") from exc diff --git a/leap0/_utils/stream.py b/leap0/_utils/stream.py new file mode 100644 index 0000000..18c5b89 --- /dev/null +++ b/leap0/_utils/stream.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import json +from typing import Any, Iterable, Iterator + + +def iter_ndjson(lines: Iterable[str]) -> Iterator[dict[str, Any]]: + for line in lines: + raw = line.strip() + if raw: + yield json.loads(raw) + + +def _sse_data_value(raw: str) -> str: + """Extract the value from a 'data:' SSE field, stripping at most one leading space per spec.""" + value = raw[5:] + if value.startswith(" "): + value = value[1:] + return value + + +def iter_sse_events(lines: Iterable[str]) -> Iterator[dict[str, Any]]: + buffer: list[str] = [] + for line in lines: + stripped = line.rstrip("\r") + if stripped == "": + if buffer: + data_lines = [_sse_data_value(item) for item in buffer if item.startswith("data:")] + if data_lines: + yield json.loads("\n".join(data_lines)) + buffer.clear() + continue + if stripped.startswith(":"): + continue + buffer.append(stripped) + if buffer: + data_lines = [_sse_data_value(item) for item in buffer if item.startswith("data:")] + if data_lines: + yield json.loads("\n".join(data_lines)) diff --git a/leap0/_utils/url.py b/leap0/_utils/url.py new file mode 100644 index 0000000..47c7837 --- /dev/null +++ b/leap0/_utils/url.py @@ -0,0 +1,27 @@ +from __future__ import annotations + + +def ensure_leading_slash(value: str) -> str: + return value if value.startswith("/") else f"/{value}" + + +def sandbox_base_url(sandbox_id: str, sandbox_domain: str | None, *, port: int | None = None) -> str: + if not sandbox_domain: + raise ValueError("sandbox_domain is required for sandbox host operations") + subdomain = f"{sandbox_id}-{port}" if port is not None else sandbox_id + host = f"{subdomain}.{sandbox_domain.strip('/')}" + return f"https://{host}" + + +def websocket_url_from_http(url: str) -> str: + if url.startswith("https://"): + return url.replace("https://", "wss://", 1) + if url.startswith("http://"): + return url.replace("http://", "ws://", 1) + return url + + +def file_uri(path: str) -> str: + """Build a file:// URI for a sandbox-side absolute path.""" + clean = path if path.startswith("/") else f"/{path}" + return f"file://{clean}" diff --git a/leap0/client.py b/leap0/client.py index d897a43..a6c1607 100644 --- a/leap0/client.py +++ b/leap0/client.py @@ -7,15 +7,16 @@ from ._transport import Transport from .code_interpreter import CodeInterpreterClient from .desktop import DesktopClient -from .config import DEFAULT_CLIENT_TIMEOUT, Leap0Config -from .constants import ( +from .common.config import ( DEFAULT_BASE_URL, + DEFAULT_CLIENT_TIMEOUT, DEFAULT_DESKTOP_TEMPLATE_NAME, DEFAULT_MEMORY_MIB, DEFAULT_SANDBOX_DOMAIN, DEFAULT_TEMPLATE_NAME, DEFAULT_TIMEOUT_MIN, DEFAULT_VCPU, + Leap0Config, ) from .filesystem import FilesystemClient from .git import GitClient diff --git a/leap0/code_interpreter.py b/leap0/code_interpreter.py index 5fc97e9..7441f7b 100644 --- a/leap0/code_interpreter.py +++ b/leap0/code_interpreter.py @@ -6,9 +6,13 @@ import httpx from ._transport import Transport -from ._types import CodeContextDict, CodeExecutionResultDict, StreamEventDict -from . import _utils -from .models import CodeContext, CodeExecutionResult, SandboxRef, StreamEvent, sandbox_id_of +from ._utils.errors import intercept_errors +from ._utils.stream import iter_sse_events +from ._utils.url import sandbox_base_url +from .common.code_interpreter import ( + CodeContext, CodeContextDict, CodeExecutionResult, CodeExecutionResultDict, StreamEvent, StreamEventDict, +) +from .common.sandbox import SandboxRef, sandbox_id_of class CodeInterpreterClient: @@ -33,7 +37,7 @@ def _request( ) -> httpx.Response: return self._transport.request_target( method, - f"{_utils.sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", + f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", json=json, expected_status=expected_status, ) @@ -49,16 +53,18 @@ def _request_json( ) -> dict[str, Any]: return self._transport.request_target_json( method, - f"{_utils.sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", + f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", json=json, expected_status=expected_status, ) + @intercept_errors("Failed to check interpreter health: ") def health(self, sandbox: SandboxRef) -> bool: """Check if the code interpreter is healthy. Returns True if status is ok.""" data = self._request_json("GET", sandbox, "/healthz") return data.get("status") == "ok" + @intercept_errors("Failed to create execution context: ") def create_context(self, sandbox: SandboxRef, *, language: str = "python", cwd: str | None = None) -> CodeContext: """Create a new execution context. @@ -73,6 +79,7 @@ def create_context(self, sandbox: SandboxRef, *, language: str = "python", cwd: data: CodeContextDict = self._request_json("POST", sandbox, "/contexts", json=payload, expected_status=201) # type: ignore[assignment] return CodeContext.from_dict(data) + @intercept_errors("Failed to list execution contexts: ") def list_contexts(self, sandbox: SandboxRef) -> list[CodeContext]: """List all execution contexts in the sandbox.""" raw = self._request_json("GET", sandbox, "/contexts") @@ -80,15 +87,18 @@ def list_contexts(self, sandbox: SandboxRef) -> list[CodeContext]: items: list[CodeContextDict] = raw.get("items", []) # type: ignore[assignment] return [CodeContext.from_dict(item) for item in items] + @intercept_errors("Failed to get execution context: ") def get_context(self, sandbox: SandboxRef, context_id: str) -> CodeContext: """Get a single execution context by ID.""" data: CodeContextDict = self._request_json("GET", sandbox, f"/contexts/{context_id}") # type: ignore[assignment] return CodeContext.from_dict(data) + @intercept_errors("Failed to delete execution context: ") def delete_context(self, sandbox: SandboxRef, context_id: str) -> None: """Delete an execution context.""" self._request("DELETE", sandbox, f"/contexts/{context_id}", expected_status=204) + @intercept_errors("Failed to execute code: ") def execute( self, sandbox: SandboxRef, @@ -119,8 +129,9 @@ def execute( payload["timeout_ms"] = timeout_ms response = self._request("POST", sandbox, "/execute", json=payload) data: CodeExecutionResultDict = response.json() # type: ignore[assignment] - return CodeExecutionResult.from_dict(data, context_id=response.headers.get("X-Context-Id")) + return CodeExecutionResult.from_dict(data) + @intercept_errors("Failed to execute code: ") def execute_stream( self, sandbox: SandboxRef, @@ -149,11 +160,11 @@ def execute_stream( payload["timeout_ms"] = timeout_ms response = self._transport.stream( "POST", - f"{_utils.sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}/execute/async", + f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}/execute/async", json=payload, ) try: - for event in _utils.iter_sse_events(response.iter_lines()): + for event in iter_sse_events(response.iter_lines()): yield StreamEvent.from_dict(cast(StreamEventDict, event)) finally: response.close() diff --git a/leap0/common/__init__.py b/leap0/common/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/leap0/common/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/leap0/common/code_interpreter.py b/leap0/common/code_interpreter.py new file mode 100644 index 0000000..8150e0b --- /dev/null +++ b/leap0/common/code_interpreter.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import base64 +from dataclasses import dataclass, field +from typing import Any, Literal, TypedDict + + +LanguageLiteral = Literal["python", "typescript"] + + +class CodeExecutionOutputDict(TypedDict, total=False): + is_primary: bool + text: str | None + html: str | None + markdown: str | None + svg: str | None + png: str | None + jpeg: str | None + pdf: str | None + latex: str | None + json: dict[str, Any] | None + javascript: str | None + extra: dict[str, Any] | None + + +class ExecutionErrorDict(TypedDict, total=False): + name: str + value: str + traceback: str + + +class ExecutionLogsDict(TypedDict, total=False): + stdout: list[str] + stderr: list[str] + + +class CodeExecutionResultDict(TypedDict, total=False): + context_id: str + items: list[CodeExecutionOutputDict] + logs: ExecutionLogsDict + error: ExecutionErrorDict | None + execution_count: int | None + + +StreamEventTypeLiteral = Literal["stdout", "stderr", "exit", "error"] + + +class StreamEventDict(TypedDict, total=False): + type: int | str + data: str + code: int | None + + +class CodeContextDict(TypedDict, total=False): + id: str + language: int | str + cwd: str + + +class ListContextsResponseDict(TypedDict): + items: list[CodeContextDict] + + +_LANGUAGE_INT_TO_STR: dict[int, str] = {1: "python", 2: "typescript"} + + +def _normalize_language(value: int | str | None) -> str: + if isinstance(value, int): + return _LANGUAGE_INT_TO_STR.get(value, str(value)) + return str(value) if value else "" + + +@dataclass(slots=True) +class CodeExecutionOutput: + is_primary: bool = False + text: str | None = None + png: str | None = None + svg: str | None = None + html: str | None = None + markdown: str | None = None + json_data: dict[str, Any] | None = None + jpeg: str | None = None + pdf: str | None = None + latex: str | None = None + javascript: str | None = None + extra: dict[str, Any] | None = None + + @classmethod + def from_dict(cls, data: CodeExecutionOutputDict) -> CodeExecutionOutput: + return cls( + is_primary=bool(data.get("is_primary", False)), + text=data.get("text"), + png=data.get("png"), + svg=data.get("svg"), + html=data.get("html"), + markdown=data.get("markdown"), + json_data=data.get("json"), + jpeg=data.get("jpeg"), + pdf=data.get("pdf"), + latex=data.get("latex"), + javascript=data.get("javascript"), + extra=data.get("extra"), + ) + + @property + def is_main_result(self) -> bool: + return self.is_primary + + def png_bytes(self) -> bytes | None: + return base64.b64decode(self.png) if self.png else None + + def jpeg_bytes(self) -> bytes | None: + return base64.b64decode(self.jpeg) if self.jpeg else None + + def pdf_bytes(self) -> bytes | None: + return base64.b64decode(self.pdf) if self.pdf else None + + +@dataclass(slots=True) +class CodeExecutionError: + name: str + value: str + traceback: str + + @classmethod + def from_dict(cls, data: ExecutionErrorDict) -> CodeExecutionError: + return cls( + name=data.get("name", ""), + value=data.get("value", ""), + traceback=data.get("traceback", ""), + ) + + +@dataclass(slots=True) +class ExecutionLogs: + stdout: list[str] = field(default_factory=list) + stderr: list[str] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: ExecutionLogsDict) -> ExecutionLogs: + return cls( + stdout=data.get("stdout") or [], + stderr=data.get("stderr") or [], + ) + + +@dataclass(slots=True) +class CodeExecutionResult: + items: list[CodeExecutionOutput] + logs: ExecutionLogs + error: CodeExecutionError | None + execution_count: int | None + context_id: str | None = None + + @classmethod + def from_dict(cls, data: CodeExecutionResultDict) -> CodeExecutionResult: + error = data.get("error") + logs_data = data.get("logs", {}) + return cls( + items=[CodeExecutionOutput.from_dict(item) for item in data.get("items", [])], + logs=ExecutionLogs.from_dict(logs_data) if logs_data else ExecutionLogs(), # type: ignore[arg-type] + error=CodeExecutionError.from_dict(error) if isinstance(error, dict) else None, + execution_count=data.get("execution_count"), + context_id=data.get("context_id"), + ) + + @property + def main_text(self) -> str | None: + for result in self.items: + if result.is_primary: + return result.text + return self.items[-1].text if self.items else None + + +_STREAM_TYPE_INT_TO_STR: dict[int, str] = {0: "stdout", 1: "stderr", 2: "exit", 3: "error"} + +StreamEventType = Literal["stdout", "stderr", "exit", "error"] + + +@dataclass(slots=True) +class StreamEvent: + type: str + data: str = "" + code: int | None = None + + @classmethod + def from_dict(cls, data: StreamEventDict) -> StreamEvent: + raw_type = data.get("type", "") + if isinstance(raw_type, int): + event_type = _STREAM_TYPE_INT_TO_STR.get(raw_type, str(raw_type)) + else: + event_type = str(raw_type) + return cls( + type=event_type, + data=data.get("data", ""), + code=data.get("code"), + ) + + +@dataclass(slots=True) +class CodeContext: + id: str + language: str = "" + cwd: str = "" + + @classmethod + def from_dict(cls, data: CodeContextDict) -> CodeContext: + return cls( + id=data.get("id", ""), + language=_normalize_language(data.get("language")), + cwd=data.get("cwd", ""), + ) diff --git a/leap0/config.py b/leap0/common/config.py similarity index 85% rename from leap0/config.py rename to leap0/common/config.py index 0fa0edf..9f84643 100644 --- a/leap0/config.py +++ b/leap0/common/config.py @@ -1,11 +1,16 @@ from __future__ import annotations -from dataclasses import dataclass import os - -from .constants import DEFAULT_BASE_URL, DEFAULT_SANDBOX_DOMAIN +from dataclasses import dataclass +DEFAULT_BASE_URL = "https://api.leap0.dev" +DEFAULT_SANDBOX_DOMAIN = "sandbox.leap0.dev" +DEFAULT_TEMPLATE_NAME = "system/code-interpreter:v0.1.0" +DEFAULT_DESKTOP_TEMPLATE_NAME = "system/desktop:v0.1.0" +DEFAULT_VCPU = 1 +DEFAULT_MEMORY_MIB = 1024 +DEFAULT_TIMEOUT_MIN = 5 DEFAULT_CLIENT_TIMEOUT = 300.0 diff --git a/leap0/common/desktop.py b/leap0/common/desktop.py new file mode 100644 index 0000000..76be35f --- /dev/null +++ b/leap0/common/desktop.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal, TypedDict, cast + + +class DesktopDisplayInfoDict(TypedDict, total=False): + display: str + width: int + height: int + + +class DesktopWindowDict(TypedDict, total=False): + id: str + desktop: int + pid: int + x: int + y: int + width: int + height: int + class_: str + host: str + title: str + focused: bool + + +class DesktopWindowsDict(TypedDict): + items: list[DesktopWindowDict] + + +class DesktopPointerPositionDict(TypedDict, total=False): + x: int + y: int + + +class DesktopRecordingStatusDict(TypedDict, total=False): + id: str + active: bool + started_at: str + stopped_at: str + download: str + mime_type: str + file_name: str + display: str + resolution: str + + +class DesktopRecordingSummaryDict(TypedDict, total=False): + id: str + file_name: str + download: str + mime_type: str + size_bytes: int + created_at: str + active: bool + + +class DesktopHealthDict(TypedDict, total=False): + ok: bool + + +class DesktopProcessStatusDict(TypedDict, total=False): + name: str + running: bool + pid: int + stdout_log: str + stderr_log: str + + +class DesktopProcessStatusListDict(TypedDict, total=False): + status: str + items: list[DesktopProcessStatusDict] + running: int + total: int + + +class DesktopProcessRestartDict(TypedDict, total=False): + message: str + status: DesktopProcessStatusDict + + +class DesktopProcessLogsDict(TypedDict, total=False): + process: str + logs: str + + +class DesktopProcessErrorsDict(TypedDict, total=False): + process: str + errors: str + + +@dataclass(slots=True) +class DesktopDisplayInfo: + display: str = "" + width: int = 0 + height: int = 0 + + @classmethod + def from_dict(cls, data: DesktopDisplayInfoDict) -> DesktopDisplayInfo: + return cls( + display=data.get("display", ""), + width=int(data.get("width", 0)), + height=int(data.get("height", 0)), + ) + + +@dataclass(slots=True) +class DesktopWindow: + id: str = "" + desktop: int = 0 + pid: int = 0 + x: int = 0 + y: int = 0 + width: int = 0 + height: int = 0 + window_class: str = "" + host: str = "" + title: str = "" + focused: bool = False + + @classmethod + def from_dict(cls, data: DesktopWindowDict) -> DesktopWindow: + return cls( + id=data.get("id", ""), + desktop=int(data.get("desktop", 0)), + pid=int(data.get("pid", 0)), + x=int(data.get("x", 0)), + y=int(data.get("y", 0)), + width=int(data.get("width", 0)), + height=int(data.get("height", 0)), + window_class=data.get("class", data.get("class_", "")), + host=data.get("host", ""), + title=data.get("title", ""), + focused=bool(data.get("focused", False)), + ) + + +@dataclass(slots=True) +class DesktopPointerPosition: + x: int = 0 + y: int = 0 + + @classmethod + def from_dict(cls, data: DesktopPointerPositionDict) -> DesktopPointerPosition: + return cls(x=int(data.get("x", 0)), y=int(data.get("y", 0))) + + +@dataclass(slots=True) +class DesktopRecordingStatus: + id: str = "" + active: bool = False + started_at: str = "" + stopped_at: str = "" + download: str = "" + mime_type: str = "" + file_name: str = "" + display: str = "" + resolution: str = "" + + @classmethod + def from_dict(cls, data: DesktopRecordingStatusDict) -> DesktopRecordingStatus: + return cls( + id=data.get("id", ""), + active=bool(data.get("active", False)), + started_at=data.get("started_at", ""), + stopped_at=data.get("stopped_at", ""), + download=data.get("download", ""), + mime_type=data.get("mime_type", ""), + file_name=data.get("file_name", ""), + display=data.get("display", ""), + resolution=data.get("resolution", ""), + ) + + +@dataclass(slots=True) +class DesktopRecordingSummary: + id: str = "" + file_name: str = "" + download: str = "" + mime_type: str = "" + size_bytes: int = 0 + created_at: str = "" + active: bool = False + + @classmethod + def from_dict(cls, data: DesktopRecordingSummaryDict) -> DesktopRecordingSummary: + return cls( + id=data.get("id", ""), + file_name=data.get("file_name", ""), + download=data.get("download", ""), + mime_type=data.get("mime_type", ""), + size_bytes=int(data.get("size_bytes", 0)), + created_at=data.get("created_at", ""), + active=bool(data.get("active", False)), + ) + + +@dataclass(slots=True) +class DesktopHealth: + ok: bool = False + + @classmethod + def from_dict(cls, data: DesktopHealthDict) -> DesktopHealth: + return cls(ok=bool(data.get("ok", False))) + + +@dataclass(slots=True) +class DesktopProcessStatus: + name: str = "" + running: bool = False + pid: int = 0 + stdout_log: str = "" + stderr_log: str = "" + + @classmethod + def from_dict(cls, data: DesktopProcessStatusDict) -> DesktopProcessStatus: + return cls( + name=data.get("name", ""), + running=bool(data.get("running", False)), + pid=int(data.get("pid", 0)), + stdout_log=data.get("stdout_log", ""), + stderr_log=data.get("stderr_log", ""), + ) + + +@dataclass(slots=True) +class DesktopProcessStatusList: + status: str = "" + items: list[DesktopProcessStatus] = field(default_factory=list) + running: int = 0 + total: int = 0 + + @classmethod + def from_dict(cls, data: DesktopProcessStatusListDict) -> DesktopProcessStatusList: + return cls( + status=data.get("status", ""), + items=[DesktopProcessStatus.from_dict(cast(DesktopProcessStatusDict, cast(object, item))) for item in data.get("items", [])], + running=int(data.get("running", 0)), + total=int(data.get("total", 0)), + ) + + +@dataclass(slots=True) +class DesktopProcessRestart: + message: str = "" + status: DesktopProcessStatus | None = None + + @classmethod + def from_dict(cls, data: DesktopProcessRestartDict) -> DesktopProcessRestart: + status = data.get("status") + return cls( + message=data.get("message", ""), + status=DesktopProcessStatus.from_dict(cast(DesktopProcessStatusDict, cast(object, status))) if isinstance(status, dict) else None, + ) + + +@dataclass(slots=True) +class DesktopProcessLogs: + process: str = "" + logs: str = "" + + @classmethod + def from_dict(cls, data: DesktopProcessLogsDict) -> DesktopProcessLogs: + return cls(process=data.get("process", ""), logs=data.get("logs", "")) + + +@dataclass(slots=True) +class DesktopProcessErrors: + process: str = "" + errors: str = "" + + @classmethod + def from_dict(cls, data: DesktopProcessErrorsDict) -> DesktopProcessErrors: + return cls(process=data.get("process", ""), errors=data.get("errors", "")) diff --git a/leap0/common/errors.py b/leap0/common/errors.py new file mode 100644 index 0000000..acca4df --- /dev/null +++ b/leap0/common/errors.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import json +from collections.abc import Mapping +from typing import Any + + +class Leap0Error(Exception): + """Base error for the Leap0 SDK. + + Every SDK exception carries optional HTTP context so callers can + inspect the original response without parsing strings. + + Attributes: + message: Human-readable error description. + status_code: HTTP status code, if the error originated from an API response. + headers: Response headers from the API, if available. + error_message: Parsed ``message`` field from a JSON response body, if present. + """ + + def __init__( + self, + message: str, + status_code: int | None = None, + headers: Mapping[str, Any] | None = None, + *, + body: str | None = None, + ): + self.message = message + self.status_code: int | None = status_code + self.headers: dict[str, Any] = dict(headers or {}) + self.body: str | None = body + self.error_message: str | None = None + if body: + try: + parsed = json.loads(body) + if isinstance(parsed, dict): + self.error_message = parsed.get("message") + except (json.JSONDecodeError, TypeError): + pass + detail = message + if status_code is not None: + detail = f"{status_code} {detail}" + if self.error_message: + detail = f"{detail}: {self.error_message}" + elif body: + detail = f"{detail}: {body}" + super().__init__(detail) + + +class Leap0NotFoundError(Leap0Error): + """The requested resource does not exist (HTTP 404). + + Raised when a sandbox, file, directory, PTY session, LSP server, + SSH access, or other resource cannot be found. + """ + + +class Leap0PermissionError(Leap0Error): + """Permission denied for the requested operation (HTTP 403). + + Raised when the sandbox filesystem denies access due to + file permissions or ownership. + """ + + +class Leap0ConflictError(Leap0Error): + """The operation conflicts with the current resource state (HTTP 409). + + Raised when a resource already exists (e.g. ``mkdir`` on an existing + directory), or when there are too many active sessions. + """ + + +class Leap0RateLimitError(Leap0Error): + """Rate limit exceeded (HTTP 429). + + Callers should back off and retry after the interval indicated + by the ``Retry-After`` header, if present. + """ + + +class Leap0TimeoutError(Leap0Error): + """The operation timed out. + + Raised when a sandbox operation or API call exceeds its deadline. + """ + + +class Leap0WebSocketError(Leap0Error): + """A WebSocket connection to a sandbox failed.""" + + +# Status-code mapping used by the transport layer +_STATUS_TO_EXCEPTION: dict[int, type[Leap0Error]] = { + 403: Leap0PermissionError, + 404: Leap0NotFoundError, + 409: Leap0ConflictError, + 429: Leap0RateLimitError, +} + + +def raise_api_error( + status_code: int, + message: str, + *, + body: str | None = None, + headers: Mapping[str, Any] | None = None, +) -> None: + """Raise the most specific ``Leap0Error`` subclass for *status_code*. + + Unmapped codes (400, 401, 422, 500, 502, 503, etc.) produce a plain + ``Leap0Error`` with the ``status_code`` attribute set so callers can + still branch on it when needed. + """ + cls = _STATUS_TO_EXCEPTION.get(status_code, Leap0Error) + raise cls(message, status_code, headers, body=body) diff --git a/leap0/common/filesystem.py b/leap0/common/filesystem.py new file mode 100644 index 0000000..38dd25d --- /dev/null +++ b/leap0/common/filesystem.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TypedDict + + +class FileInfoDict(TypedDict, total=False): + name: str + path: str + is_dir: bool + size: int + mode: str + mtime: int + owner: str + group: str + is_symlink: bool + link_target: str + + +class LsResponseDict(TypedDict): + items: list[FileInfoDict] + + +class GlobResponseDict(TypedDict): + items: list[str] + + +class SearchMatchDict(TypedDict, total=False): + path: str + line: int + content: str + + +class GrepResponseDict(TypedDict): + items: list[SearchMatchDict] + + +class EditFileResponseDict(TypedDict, total=False): + diff: str + replacements: int + + +class EditResultDict(TypedDict, total=False): + file: str + success: bool + error: str + + +class EditFilesResponseDict(TypedDict): + items: list[EditResultDict] + + +class ExistsResponseDict(TypedDict): + exists: bool + + +class TreeEntryDict(TypedDict, total=False): + name: str + type: str + children: list[TreeEntryDict] + + +class TreeResponseDict(TypedDict): + items: list[TreeEntryDict] + + +@dataclass(slots=True) +class FileInfo: + name: str + path: str + is_dir: bool = False + size: int = 0 + mode: str = "" + mtime: int = 0 + owner: str = "" + group: str = "" + is_symlink: bool = False + link_target: str = "" + + @classmethod + def from_dict(cls, data: FileInfoDict) -> FileInfo: + return cls( + name=data.get("name", ""), + path=data.get("path", ""), + is_dir=bool(data.get("is_dir", False)), + size=int(data.get("size", 0)), + mode=data.get("mode", ""), + mtime=int(data.get("mtime", 0)), + owner=data.get("owner", ""), + group=data.get("group", ""), + is_symlink=bool(data.get("is_symlink", False)), + link_target=data.get("link_target", ""), + ) + + +@dataclass(slots=True) +class LsResult: + items: list[FileInfo] + + @classmethod + def from_dict(cls, data: LsResponseDict) -> LsResult: + return cls(items=[FileInfo.from_dict(item) for item in data.get("items", [])]) + + +@dataclass(slots=True) +class FileEdit: + find: str + replace: str = "" + + def to_dict(self) -> dict[str, str]: + return {"find": self.find, "replace": self.replace} + + +@dataclass(slots=True) +class EditFileResult: + diff: str = "" + replacements: int = 0 + + @classmethod + def from_dict(cls, data: EditFileResponseDict) -> EditFileResult: + return cls( + diff=data.get("diff", ""), + replacements=int(data.get("replacements", 0)), + ) + + +@dataclass(slots=True) +class EditResult: + file: str = "" + success: bool = False + error: str = "" + + @classmethod + def from_dict(cls, data: EditResultDict) -> EditResult: + return cls( + file=data.get("file", ""), + success=bool(data.get("success", False)), + error=data.get("error", ""), + ) + + +@dataclass(slots=True) +class SearchMatch: + path: str = "" + line: int = 0 + content: str = "" + + @classmethod + def from_dict(cls, data: SearchMatchDict) -> SearchMatch: + return cls( + path=data.get("path", ""), + line=int(data.get("line", 0)), + content=data.get("content", ""), + ) + + +@dataclass(slots=True) +class TreeEntry: + name: str + type: str = "file" + children: list[TreeEntry] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: TreeEntryDict) -> TreeEntry: + return cls( + name=data.get("name", ""), + type=data.get("type", "file"), + children=[TreeEntry.from_dict(c) for c in data.get("children", [])], + ) + + +@dataclass(slots=True) +class TreeResult: + items: list[TreeEntry] + + @classmethod + def from_dict(cls, data: TreeResponseDict) -> TreeResult: + return cls(items=[TreeEntry.from_dict(item) for item in data.get("items", [])]) diff --git a/leap0/common/git.py b/leap0/common/git.py new file mode 100644 index 0000000..9ffc9de --- /dev/null +++ b/leap0/common/git.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypedDict + + +class GitResultDict(TypedDict, total=False): + output: str + exit_code: int + + +class GitCommitResponseDict(TypedDict, total=False): + sha: str | None + result: GitResultDict | None + + +@dataclass(slots=True) +class GitResult: + output: str + exit_code: int + + @classmethod + def from_dict(cls, data: GitResultDict) -> GitResult: + return cls(output=data.get("output", ""), exit_code=int(data.get("exit_code", 0))) + + +@dataclass(slots=True) +class GitCommitResult: + sha: str | None + result: GitResult | None + + @classmethod + def from_dict(cls, data: GitCommitResponseDict) -> GitCommitResult: + result_data = data.get("result") + return cls( + sha=data.get("sha"), + result=GitResult.from_dict(result_data) if isinstance(result_data, dict) else None, + ) diff --git a/leap0/common/lsp.py b/leap0/common/lsp.py new file mode 100644 index 0000000..9065434 --- /dev/null +++ b/leap0/common/lsp.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypedDict + + +class LspSuccessResponseDict(TypedDict, total=False): + success: bool + + +@dataclass(slots=True) +class LspResponse: + success: bool = False + + @classmethod + def from_dict(cls, data: LspSuccessResponseDict) -> LspResponse: + return cls(success=bool(data.get("success", False))) diff --git a/leap0/common/process.py b/leap0/common/process.py new file mode 100644 index 0000000..2728ef9 --- /dev/null +++ b/leap0/common/process.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypedDict + + +class ProcessResultDict(TypedDict, total=False): + exit_code: int + result: str + + +@dataclass(slots=True) +class ProcessResult: + exit_code: int + result: str + + @classmethod + def from_dict(cls, data: ProcessResultDict) -> ProcessResult: + return cls(exit_code=int(data.get("exit_code", 0)), result=data.get("result", "")) diff --git a/leap0/common/pty.py b/leap0/common/pty.py new file mode 100644 index 0000000..0adc25d --- /dev/null +++ b/leap0/common/pty.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TypedDict + +from websockets.sync.client import ClientConnection + + +class PtyCreateResponseDict(TypedDict, total=False): + session_id: str + + +class PtySessionInfoDict(TypedDict, total=False): + id: str + session_id: str + cwd: str + envs: dict[str, str] + cols: int + rows: int + created_at: str + active: bool + lazy_start: bool + + +class PtyListResponseDict(TypedDict, total=False): + items: list[PtySessionInfoDict] + + +@dataclass(slots=True) +class PtySession: + id: str = "" + cwd: str = "" + envs: dict[str, str] = field(default_factory=dict) + cols: int = 0 + rows: int = 0 + created_at: str = "" + active: bool = False + lazy_start: bool = False + + @classmethod + def from_dict(cls, data: PtySessionInfoDict) -> PtySession: + return cls( + id=data.get("id", data.get("session_id", "")), + cwd=data.get("cwd", ""), + envs=data.get("envs") or {}, + cols=int(data.get("cols", 0)), + rows=int(data.get("rows", 0)), + created_at=data.get("created_at", ""), + active=bool(data.get("active", False)), + lazy_start=bool(data.get("lazy_start", False)), + ) + + +@dataclass(slots=True) +class PtyConnection: + websocket: ClientConnection + + def send(self, data: str | bytes) -> None: + payload = data.encode() if isinstance(data, str) else data + self.websocket.send(payload) + + def recv(self) -> bytes: + message = self.websocket.recv() + if isinstance(message, str): + return message.encode() + if isinstance(message, bytes): + return message + return b"".join(message) + + def close(self) -> None: + self.websocket.close() diff --git a/leap0/common/sandbox.py b/leap0/common/sandbox.py new file mode 100644 index 0000000..6112058 --- /dev/null +++ b/leap0/common/sandbox.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal, TypedDict + +from typing_extensions import NotRequired, Required + + +SandboxState = Literal["starting", "running", "paused", "unpausing", "deleting", "deleted"] + + +class TransformRuleDict(TypedDict, total=False): + domain: Required[str] + inject_headers: NotRequired[dict[str, str]] + strip_headers: NotRequired[list[str]] + + +class NetworkPolicyDict(TypedDict, total=False): + mode: Required[Literal["allow-all", "deny-all", "custom"]] + allow_domains: NotRequired[list[str]] + allow_cidrs: NotRequired[list[str]] + transforms: NotRequired[list[TransformRuleDict]] + + +class SandboxCreateResponseDict(TypedDict): + id: str + template_id: str + vcpu: int + memory_mib: int + disk_mib: int + state: SandboxState + auto_pause: bool + created_at: str + network_policy: NetworkPolicyDict | None + + +class SandboxStatusResponseDict(TypedDict): + id: str + template_id: str + vcpu: int + memory_mib: int + disk_mib: int + state: SandboxState + auto_pause: bool + created_at: str + + +@dataclass(slots=True) +class Sandbox: + id: str + template_id: str = "" + vcpu: int = 0 + memory_mib: int = 0 + disk_mib: int = 0 + state: SandboxState = "starting" + auto_pause: bool = False + created_at: str = "" + network_policy: NetworkPolicyDict | None = None + + @classmethod + def from_dict(cls, data: SandboxCreateResponseDict) -> Sandbox: + state = data.get("state", "starting") + return cls( + id=data["id"], + template_id=data.get("template_id", ""), + vcpu=int(data.get("vcpu", 0)), + memory_mib=int(data.get("memory_mib", 0)), + disk_mib=int(data.get("disk_mib", 0)), + state=state, # type: ignore[arg-type] + auto_pause=bool(data.get("auto_pause", False)), + created_at=data.get("created_at", ""), + network_policy=data.get("network_policy"), + ) + + +@dataclass(slots=True) +class SandboxStatus: + id: str + template_id: str + vcpu: int + memory_mib: int + disk_mib: int + state: SandboxState + auto_pause: bool + created_at: str + + @classmethod + def from_dict(cls, data: SandboxStatusResponseDict) -> SandboxStatus: + state = data.get("state", "starting") + return cls( + id=data.get("id", ""), + template_id=data.get("template_id", ""), + vcpu=int(data.get("vcpu", 0)), + memory_mib=int(data.get("memory_mib", 0)), + disk_mib=int(data.get("disk_mib", 0)), + state=state, # type: ignore[arg-type] + auto_pause=bool(data.get("auto_pause", False)), + created_at=data.get("created_at", ""), + ) + + +SandboxRef = str | Sandbox | SandboxStatus + + +def sandbox_id_of(value: SandboxRef) -> str: + if isinstance(value, str): + return value + return value.id diff --git a/leap0/common/snapshot.py b/leap0/common/snapshot.py new file mode 100644 index 0000000..359eeb1 --- /dev/null +++ b/leap0/common/snapshot.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypedDict + +from .sandbox import NetworkPolicyDict, SandboxState + + +class SnapshotCreateResponseDict(TypedDict, total=False): + snapshot_id: str + name: str + template_id: str + vcpu: int + memory_mib: int + disk_mib: int + state: SandboxState + created_at: str + network_policy: NetworkPolicyDict | None + + +@dataclass(slots=True) +class Snapshot: + snapshot_id: str + name: str + template_id: str = "" + vcpu: int = 0 + memory_mib: int = 0 + disk_mib: int = 0 + state: SandboxState | str = "" + network_policy: NetworkPolicyDict | None = None + created_at: str = "" + + @property + def id(self) -> str: + return self.snapshot_id + + @classmethod + def from_dict(cls, data: SnapshotCreateResponseDict) -> Snapshot: + return cls( + snapshot_id=data.get("snapshot_id", ""), + name=data.get("name", ""), + template_id=data.get("template_id", ""), + vcpu=int(data.get("vcpu", 0)), + memory_mib=int(data.get("memory_mib", 0)), + disk_mib=int(data.get("disk_mib", 0)), + state=data.get("state", ""), + network_policy=data.get("network_policy"), + created_at=data.get("created_at", ""), + ) + + +SnapshotRef = str | Snapshot + + +def snapshot_id_of(value: SnapshotRef) -> str: + if isinstance(value, str): + return value + return value.snapshot_id diff --git a/leap0/common/ssh.py b/leap0/common/ssh.py new file mode 100644 index 0000000..2aa829a --- /dev/null +++ b/leap0/common/ssh.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypedDict + + +class SshCreateAccessDict(TypedDict, total=False): + id: str + sandbox_id: str + password: str + expires_at: str + created_at: str + updated_at: str + ssh_command: str + + +class SshAccessValidationDict(TypedDict, total=False): + valid: bool + sandbox_id: str + + +@dataclass(slots=True) +class SshAccess: + id: str + password: str + ssh_command: str + sandbox_id: str = "" + expires_at: str = "" + created_at: str = "" + updated_at: str = "" + + @classmethod + def from_dict(cls, data: SshCreateAccessDict) -> SshAccess: + return cls( + id=data.get("id", ""), + password=data.get("password", ""), + ssh_command=data.get("ssh_command", ""), + sandbox_id=data.get("sandbox_id", ""), + expires_at=data.get("expires_at", ""), + created_at=data.get("created_at", ""), + updated_at=data.get("updated_at", ""), + ) + + +@dataclass(slots=True) +class SshValidation: + valid: bool + sandbox_id: str + + @classmethod + def from_dict(cls, data: SshAccessValidationDict) -> SshValidation: + return cls( + valid=bool(data.get("valid", False)), + sandbox_id=data.get("sandbox_id", ""), + ) diff --git a/leap0/common/template.py b/leap0/common/template.py new file mode 100644 index 0000000..10b1a6b --- /dev/null +++ b/leap0/common/template.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal, TypedDict + + +RegistryCredentialType = Literal["basic", "aws", "gcp", "azure"] + + +class RegistryCredentialsDict(TypedDict, total=False): + type: RegistryCredentialType + username: str + password: str + aws_access_key_id: str + aws_secret_access_key: str + aws_region: str + gcp_service_account_json: str + azure_client_id: str + azure_client_secret: str + azure_tenant_id: str + + +class ImageConfigDict(TypedDict, total=False): + entrypoint: list[str] | None + cmd: list[str] | None + working_dir: str | None + user: str + env: dict[str, Any] | None + + +class UploadTemplateResponseDict(TypedDict): + id: str + name: str + digest: str + image_config: ImageConfigDict | None + is_system: bool + created_at: str + + +@dataclass(slots=True) +class ImageConfig: + entrypoint: list[str] = field(default_factory=list) + cmd: list[str] = field(default_factory=list) + working_dir: str | None = None + user: str = "" + env: dict[str, Any] | None = None + + @classmethod + def from_dict(cls, data: ImageConfigDict) -> ImageConfig: + return cls( + entrypoint=data.get("entrypoint") or [], + cmd=data.get("cmd") or [], + working_dir=data.get("working_dir"), + user=data.get("user", ""), + env=data.get("env"), + ) + + +@dataclass(slots=True) +class Template: + id: str + name: str + digest: str = "" + image_config: ImageConfig | None = None + is_system: bool = False + created_at: str = "" + + @classmethod + def from_dict(cls, data: UploadTemplateResponseDict) -> Template: + ic = data.get("image_config") + return cls( + id=data.get("id", ""), # type: ignore[arg-type] + name=data.get("name", ""), # type: ignore[arg-type] + digest=data.get("digest", ""), # type: ignore[arg-type] + image_config=ImageConfig.from_dict(ic) if isinstance(ic, dict) else None, + is_system=bool(data.get("is_system", False)), # type: ignore[arg-type] + created_at=data.get("created_at", ""), # type: ignore[arg-type] + ) diff --git a/leap0/constants.py b/leap0/constants.py deleted file mode 100644 index 79f8a28..0000000 --- a/leap0/constants.py +++ /dev/null @@ -1,7 +0,0 @@ -DEFAULT_BASE_URL = "https://api.leap0.dev" -DEFAULT_SANDBOX_DOMAIN = "sandbox.leap0.dev" -DEFAULT_TEMPLATE_NAME = "system/code-interpreter:v0.1.0" -DEFAULT_DESKTOP_TEMPLATE_NAME = "system/desktop:v0.1.0" -DEFAULT_VCPU = 1 -DEFAULT_MEMORY_MIB = 1024 -DEFAULT_TIMEOUT_MIN = 5 diff --git a/leap0/desktop.py b/leap0/desktop.py index a4b11a5..4cc6253 100644 --- a/leap0/desktop.py +++ b/leap0/desktop.py @@ -5,36 +5,35 @@ import httpx -from . import _utils from ._transport import Transport -from ._types import ( - DesktopDisplayInfoDict, - DesktopHealthDict, - DesktopPointerPositionDict, - DesktopProcessErrorsDict, - DesktopProcessLogsDict, - DesktopProcessRestartDict, - DesktopProcessStatusDict, - DesktopProcessStatusListDict, - DesktopRecordingStatusDict, - DesktopRecordingSummaryDict, - DesktopWindowsDict, -) -from .models import ( +from ._utils.errors import intercept_errors +from ._utils.stream import iter_sse_events +from ._utils.url import sandbox_base_url +from .common.desktop import ( DesktopDisplayInfo, + DesktopDisplayInfoDict, DesktopHealth, + DesktopHealthDict, DesktopPointerPosition, + DesktopPointerPositionDict, DesktopProcessErrors, + DesktopProcessErrorsDict, DesktopProcessLogs, + DesktopProcessLogsDict, DesktopProcessRestart, + DesktopProcessRestartDict, DesktopProcessStatus, + DesktopProcessStatusDict, DesktopProcessStatusList, + DesktopProcessStatusListDict, DesktopRecordingStatus, + DesktopRecordingStatusDict, DesktopRecordingSummary, + DesktopRecordingSummaryDict, DesktopWindow, - SandboxRef, - sandbox_id_of, + DesktopWindowsDict, ) +from .common.sandbox import SandboxRef, sandbox_id_of class DesktopClient: @@ -62,7 +61,7 @@ def _request( ) -> httpx.Response: return self._transport.request_target( method, - f"{_utils.sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", + f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", params=params, json=json, expected_status=expected_status, @@ -80,7 +79,7 @@ def _request_json( ) -> dict[str, Any]: return self._transport.request_target_json( method, - f"{_utils.sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", + f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", params=params, json=json, expected_status=expected_status, @@ -88,18 +87,21 @@ def _request_json( def desktop_url(self, sandbox: SandboxRef) -> str: """Build the browser URL for the noVNC desktop viewer.""" - return f"{_utils.sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}/" + return f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}/" + @intercept_errors("Failed to get display info: ") def display_info(self, sandbox: SandboxRef) -> DesktopDisplayInfo: """Get display information (display name, width, height).""" data: DesktopDisplayInfoDict = self._request_json("GET", sandbox, "/api/display") # type: ignore[assignment] return DesktopDisplayInfo.from_dict(data) + @intercept_errors("Failed to get screen info: ") def screen(self, sandbox: SandboxRef) -> DesktopDisplayInfo: """Get the current screen resolution.""" data: DesktopDisplayInfoDict = self._request_json("GET", sandbox, "/api/display/screen") # type: ignore[assignment] return DesktopDisplayInfo.from_dict(data) + @intercept_errors("Failed to resize screen: ") def resize_screen(self, sandbox: SandboxRef, *, width: int, height: int) -> DesktopDisplayInfo: """Resize the virtual display (width: 320-7680, height: 320-4320).""" data: DesktopDisplayInfoDict = self._request_json( # type: ignore[assignment] @@ -110,11 +112,13 @@ def resize_screen(self, sandbox: SandboxRef, *, width: int, height: int) -> Desk ) return DesktopDisplayInfo.from_dict(data) + @intercept_errors("Failed to list windows: ") def windows(self, sandbox: SandboxRef) -> list[DesktopWindow]: """List all open windows on the desktop.""" data: DesktopWindowsDict = self._request_json("GET", sandbox, "/api/display/windows") # type: ignore[assignment] return [DesktopWindow.from_dict(item) for item in data.get("items", [])] + @intercept_errors("Failed to take screenshot: ") def screenshot( self, sandbox: SandboxRef, @@ -153,6 +157,7 @@ def screenshot( response = self._request("GET", sandbox, "/api/screenshot", params=params or None) return response.content + @intercept_errors("Failed to take screenshot: ") def screenshot_region( self, sandbox: SandboxRef, @@ -173,16 +178,19 @@ def screenshot_region( response = self._request("POST", sandbox, "/api/screenshot/region", json=payload) return response.content + @intercept_errors("Failed to get pointer position: ") def pointer_position(self, sandbox: SandboxRef) -> DesktopPointerPosition: """Get the current mouse pointer position.""" data: DesktopPointerPositionDict = self._request_json("GET", sandbox, "/api/input/position") # type: ignore[assignment] return DesktopPointerPosition.from_dict(data) + @intercept_errors("Failed to move pointer: ") def move_pointer(self, sandbox: SandboxRef, *, x: int, y: int) -> DesktopPointerPosition: """Move the mouse pointer to the given coordinates.""" data: DesktopPointerPositionDict = self._request_json("POST", sandbox, "/api/input/move", json={"x": x, "y": y}) # type: ignore[assignment] return DesktopPointerPosition.from_dict(data) + @intercept_errors("Failed to click: ") def click( self, sandbox: SandboxRef, @@ -209,6 +217,7 @@ def click( data: DesktopPointerPositionDict = self._request_json("POST", sandbox, "/api/input/click", json=payload) # type: ignore[assignment] return DesktopPointerPosition.from_dict(data) + @intercept_errors("Failed to drag: ") def drag( self, sandbox: SandboxRef, @@ -231,6 +240,7 @@ def drag( data: DesktopPointerPositionDict = self._request_json("POST", sandbox, "/api/input/drag", json=payload) # type: ignore[assignment] return DesktopPointerPosition.from_dict(data) + @intercept_errors("Failed to scroll: ") def scroll(self, sandbox: SandboxRef, *, direction: str, amount: int | None = None) -> DesktopPointerPosition: """Scroll the mouse wheel. @@ -245,92 +255,109 @@ def scroll(self, sandbox: SandboxRef, *, direction: str, amount: int | None = No data: DesktopPointerPositionDict = self._request_json("POST", sandbox, "/api/input/scroll", json=payload) # type: ignore[assignment] return DesktopPointerPosition.from_dict(data) + @intercept_errors("Failed to type text: ") def type_text(self, sandbox: SandboxRef, *, text: str) -> bool: """Type text using simulated keyboard input (max 50,000 characters).""" data = self._request_json("POST", sandbox, "/api/input/type", json={"text": text}) return bool(data.get("ok", False)) + @intercept_errors("Failed to press key: ") def press_key(self, sandbox: SandboxRef, *, key: str) -> bool: """Press a single key by X11 keysym name (e.g. ``"Return"``, ``"Escape"``).""" data = self._request_json("POST", sandbox, "/api/input/press", json={"key": key}) return bool(data.get("ok", False)) + @intercept_errors("Failed to press hotkey: ") def hotkey(self, sandbox: SandboxRef, *, keys: list[str]) -> bool: """Press multiple keys simultaneously (e.g. ``["Control_L", "c"]``).""" data = self._request_json("POST", sandbox, "/api/input/hotkey", json={"keys": keys}) return bool(data.get("ok", False)) + @intercept_errors("Failed to get recording status: ") def recording_status(self, sandbox: SandboxRef) -> DesktopRecordingStatus: """Get the current screen recording status.""" data: DesktopRecordingStatusDict = self._request_json("GET", sandbox, "/api/recording") # type: ignore[assignment] return DesktopRecordingStatus.from_dict(data) + @intercept_errors("Failed to start recording: ") def start_recording(self, sandbox: SandboxRef) -> DesktopRecordingStatus: """Start recording the screen. Returns 409 if a recording is already active.""" data: DesktopRecordingStatusDict = self._request_json("POST", sandbox, "/api/recording/start", expected_status=201) # type: ignore[assignment] return DesktopRecordingStatus.from_dict(data) + @intercept_errors("Failed to stop recording: ") def stop_recording(self, sandbox: SandboxRef) -> DesktopRecordingStatus: """Stop the active screen recording.""" data: DesktopRecordingStatusDict = self._request_json("POST", sandbox, "/api/recording/stop") # type: ignore[assignment] return DesktopRecordingStatus.from_dict(data) + @intercept_errors("Failed to list recordings: ") def recordings(self, sandbox: SandboxRef) -> list[DesktopRecordingSummary]: """List all screen recordings.""" raw = self._request_json("GET", sandbox, "/api/recordings") items: list[DesktopRecordingSummaryDict] = raw.get("items", []) # type: ignore[assignment] return [DesktopRecordingSummary.from_dict(item) for item in items] + @intercept_errors("Failed to get recording: ") def get_recording(self, sandbox: SandboxRef, recording_id: str) -> DesktopRecordingSummary: """Get details for a single recording.""" data: DesktopRecordingSummaryDict = self._request_json("GET", sandbox, f"/api/recordings/{recording_id}") # type: ignore[assignment] return DesktopRecordingSummary.from_dict(data) + @intercept_errors("Failed to download recording: ") def download_recording(self, sandbox: SandboxRef, recording_id: str) -> bytes: """Download a recording as MP4 bytes.""" response = self._request("GET", sandbox, f"/api/recordings/{recording_id}/download") return response.content + @intercept_errors("Failed to delete recording: ") def delete_recording(self, sandbox: SandboxRef, recording_id: str) -> None: """Delete a recording. Cannot delete an active recording.""" self._request("DELETE", sandbox, f"/api/recordings/{recording_id}", expected_status=204) + @intercept_errors("Failed to check desktop health: ") def health(self, sandbox: SandboxRef) -> DesktopHealth: """Check the health of the desktop environment.""" - data: DesktopHealthDict = self._request_json("GET", sandbox, "/api/healthz", expected_status=(200, 503)) # type: ignore[assignment] + data: DesktopHealthDict = self._request_json("GET", sandbox, "/api/healthz") # type: ignore[assignment] return DesktopHealth.from_dict(data) + @intercept_errors("Failed to get process status: ") def process_status(self, sandbox: SandboxRef) -> DesktopProcessStatusList: """Get the status of all desktop processes (xvfb, xfce4, x11vnc, novnc).""" data: DesktopProcessStatusListDict = self._request_json("GET", sandbox, "/api/status") # type: ignore[assignment] return DesktopProcessStatusList.from_dict(data) + @intercept_errors("Failed to get process: ") def get_process(self, sandbox: SandboxRef, process_name: str) -> DesktopProcessStatus: """Get the status of a single desktop process by name.""" data: DesktopProcessStatusDict = self._request_json("GET", sandbox, f"/api/process/{process_name}/status") # type: ignore[assignment] return DesktopProcessStatus.from_dict(data) + @intercept_errors("Failed to restart process: ") def restart_process(self, sandbox: SandboxRef, process_name: str) -> DesktopProcessRestart: """Restart a desktop process.""" data: DesktopProcessRestartDict = self._request_json("POST", sandbox, f"/api/process/{process_name}/restart") # type: ignore[assignment] return DesktopProcessRestart.from_dict(data) + @intercept_errors("Failed to get process logs: ") def process_logs(self, sandbox: SandboxRef, process_name: str) -> DesktopProcessLogs: """Get stdout logs for a desktop process.""" data: DesktopProcessLogsDict = self._request_json("GET", sandbox, f"/api/process/{process_name}/logs") # type: ignore[assignment] return DesktopProcessLogs.from_dict(data) + @intercept_errors("Failed to get process errors: ") def process_errors(self, sandbox: SandboxRef, process_name: str) -> DesktopProcessErrors: """Get stderr logs for a desktop process.""" data: DesktopProcessErrorsDict = self._request_json("GET", sandbox, f"/api/process/{process_name}/errors") # type: ignore[assignment] return DesktopProcessErrors.from_dict(data) + @intercept_errors("Failed to stream status: ") def status_stream(self, sandbox: SandboxRef) -> Iterator[DesktopProcessStatusList]: """Subscribe to a live SSE stream of process status updates.""" - url = f"{_utils.sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}/api/status/stream" + url = f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}/api/status/stream" response = self._transport.stream("GET", url) try: - for event in _utils.iter_sse_events(response.iter_lines()): + for event in iter_sse_events(response.iter_lines()): yield DesktopProcessStatusList.from_dict(cast(DesktopProcessStatusListDict, event)) finally: response.close() diff --git a/leap0/exceptions.py b/leap0/exceptions.py deleted file mode 100644 index d33cfe9..0000000 --- a/leap0/exceptions.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - - -class Leap0Error(Exception): - """Base exception for all Leap0 SDK errors.""" - - -class Leap0APIError(Leap0Error): - """Raised when the Leap0 API returns an unexpected HTTP status code. - - Attributes: - status_code: HTTP status code returned by the API. - message: Human-readable error description. - body: Raw response body, if available. - """ - - def __init__(self, status_code: int, message: str, *, body: str | None = None): - self.status_code = status_code - self.message = message - self.body = body - detail = f"{status_code} {message}" - if body: - detail = f"{detail}: {body}" - super().__init__(detail) - - -class Leap0WebSocketError(Leap0Error): - """Raised when a WebSocket connection to a sandbox fails.""" diff --git a/leap0/filesystem.py b/leap0/filesystem.py index eb00c04..b837dd3 100644 --- a/leap0/filesystem.py +++ b/leap0/filesystem.py @@ -3,27 +3,25 @@ from typing import Any from ._transport import Transport -from ._types import ( +from ._utils.errors import intercept_errors +from .common.filesystem import ( EditFileResponseDict, + EditFileResult, EditFilesResponseDict, + EditResult, ExistsResponseDict, + FileEdit, + FileInfo, FileInfoDict, GlobResponseDict, GrepResponseDict, LsResponseDict, - TreeResponseDict, -) -from .models import ( - EditFileResult, - EditResult, - FileEdit, - FileInfo, LsResult, - SandboxRef, SearchMatch, + TreeResponseDict, TreeResult, - sandbox_id_of, ) +from .common.sandbox import SandboxRef, sandbox_id_of class FilesystemClient: @@ -35,6 +33,7 @@ class FilesystemClient: def __init__(self, transport: Transport): self._transport = transport + @intercept_errors("Failed to list directory: ") def ls(self, sandbox: SandboxRef, *, path: str, recursive: bool = False, exclude: list[str] | None = None) -> LsResult: """List directory entries. @@ -50,11 +49,13 @@ def ls(self, sandbox: SandboxRef, *, path: str, recursive: bool = False, exclude data: LsResponseDict = self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/ls", json=payload) # type: ignore[assignment] return LsResult.from_dict(data) + @intercept_errors("Failed to stat file: ") def stat(self, sandbox: SandboxRef, *, path: str) -> FileInfo: """Get metadata for a single path.""" data: FileInfoDict = self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/stat", json={"path": path}) # type: ignore[assignment] return FileInfo.from_dict(data) + @intercept_errors("Failed to create directory: ") def mkdir(self, sandbox: SandboxRef, *, path: str, recursive: bool = False, permissions: str | None = None) -> None: """Create a directory. Set *recursive* to create parent directories. @@ -74,6 +75,7 @@ def mkdir(self, sandbox: SandboxRef, *, path: str, recursive: bool = False, perm expected_status=204, ) + @intercept_errors("Failed to write file: ") def write_file_bytes(self, sandbox: SandboxRef, *, path: str, content: bytes, permissions: str | None = None) -> None: """Write raw bytes to a single file path.""" params: dict[str, str] = {"path": path} @@ -88,10 +90,12 @@ def write_file_bytes(self, sandbox: SandboxRef, *, path: str, content: bytes, pe expected_status=204, ) + @intercept_errors("Failed to write file: ") def write_file_text(self, sandbox: SandboxRef, *, path: str, content: str, encoding: str = "utf-8", permissions: str | None = None) -> None: """Write a string to a single file path, encoded as *encoding*.""" self.write_file_bytes(sandbox, path=path, content=content.encode(encoding), permissions=permissions) + @intercept_errors("Failed to write files: ") def write_files_bytes(self, sandbox: SandboxRef, *, files: dict[str, bytes]) -> None: """Write multiple files in a single request. @@ -106,10 +110,12 @@ def write_files_bytes(self, sandbox: SandboxRef, *, files: dict[str, bytes]) -> expected_status=204, ) + @intercept_errors("Failed to write files: ") def write_files_text(self, sandbox: SandboxRef, *, files: dict[str, str], encoding: str = "utf-8") -> None: """Write multiple text files in a single request.""" self.write_files_bytes(sandbox, files={p: c.encode(encoding) for p, c in files.items()}) + @intercept_errors("Failed to read file: ") def read_file_bytes( self, sandbox: SandboxRef, @@ -142,6 +148,7 @@ def read_file_bytes( response = self._transport.request("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/read-file", json=payload) return response.content + @intercept_errors("Failed to read file: ") def read_file_text( self, sandbox: SandboxRef, @@ -163,15 +170,18 @@ def read_file_text( tail=tail, ).decode(encoding) + @intercept_errors("Failed to read files: ") def read_files_bytes(self, sandbox: SandboxRef, *, paths: list[str]) -> dict[str, bytes]: """Read multiple files. Returns a mapping of file path to raw bytes.""" response = self._transport.request("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/read-files", json={"paths": paths}) return _parse_multipart_response(response.headers.get("content-type", ""), response.content) + @intercept_errors("Failed to read files: ") def read_files_text(self, sandbox: SandboxRef, *, paths: list[str], encoding: str = "utf-8") -> dict[str, str]: """Read multiple files. Returns a mapping of file path to decoded string.""" return {path: content.decode(encoding) for path, content in self.read_files_bytes(sandbox, paths=paths).items()} + @intercept_errors("Failed to delete: ") def delete(self, sandbox: SandboxRef, *, path: str, recursive: bool = False) -> None: """Delete a file or directory. Set *recursive* for directories with contents.""" self._transport.request( @@ -181,6 +191,7 @@ def delete(self, sandbox: SandboxRef, *, path: str, recursive: bool = False) -> expected_status=204, ) + @intercept_errors("Failed to set permissions: ") def set_permissions( self, sandbox: SandboxRef, @@ -200,6 +211,7 @@ def set_permissions( payload["group"] = group self._transport.request("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/set-permissions", json=payload, expected_status=204) + @intercept_errors("Failed to glob: ") def glob(self, sandbox: SandboxRef, *, path: str, pattern: str, exclude: list[str] | None = None) -> list[str]: """Find file paths matching a glob pattern.""" payload: dict[str, Any] = {"path": path, "pattern": pattern} @@ -208,6 +220,7 @@ def glob(self, sandbox: SandboxRef, *, path: str, pattern: str, exclude: list[st data: GlobResponseDict = self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/glob", json=payload) # type: ignore[assignment] return list(data.get("items", [])) + @intercept_errors("Failed to grep: ") def grep(self, sandbox: SandboxRef, *, path: str, pattern: str, include: str | None = None, exclude: list[str] | None = None) -> list[SearchMatch]: """Search for a text pattern across files in a directory. @@ -226,6 +239,7 @@ def grep(self, sandbox: SandboxRef, *, path: str, pattern: str, include: str | N data: GrepResponseDict = self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/grep", json=payload) # type: ignore[assignment] return [SearchMatch.from_dict(item) for item in data.get("items", [])] + @intercept_errors("Failed to edit file: ") def edit_file(self, sandbox: SandboxRef, *, path: str, edits: list[FileEdit]) -> EditFileResult: """Apply one or more find-and-replace edits to a single file. @@ -238,6 +252,7 @@ def edit_file(self, sandbox: SandboxRef, *, path: str, edits: list[FileEdit]) -> ) return EditFileResult.from_dict(data) + @intercept_errors("Failed to edit files: ") def edit_files(self, sandbox: SandboxRef, *, paths: list[str], find: str, replace: str = "") -> list[EditResult]: """Replace text across multiple files at once.""" data: EditFilesResponseDict = self._transport.request_json( # type: ignore[assignment] @@ -247,6 +262,7 @@ def edit_files(self, sandbox: SandboxRef, *, paths: list[str], find: str, replac ) return [EditResult.from_dict(item) for item in data.get("items", [])] + @intercept_errors("Failed to move: ") def move(self, sandbox: SandboxRef, *, src_path: str, dst_path: str, overwrite: bool = False) -> None: """Move or rename a file or directory.""" self._transport.request( @@ -256,6 +272,7 @@ def move(self, sandbox: SandboxRef, *, src_path: str, dst_path: str, overwrite: expected_status=204, ) + @intercept_errors("Failed to copy: ") def copy( self, sandbox: SandboxRef, @@ -271,11 +288,13 @@ def copy( payload["overwrite"] = overwrite self._transport.request("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/copy", json=payload, expected_status=204) + @intercept_errors("Failed to check path: ") def exists(self, sandbox: SandboxRef, *, path: str) -> bool: """Check whether a path exists in the sandbox.""" data: ExistsResponseDict = self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/exists", json={"path": path}) # type: ignore[assignment] return bool(data.get("exists", False)) + @intercept_errors("Failed to get directory tree: ") def tree(self, sandbox: SandboxRef, *, path: str, max_depth: int | None = None, exclude: list[str] | None = None) -> TreeResult: """Get a recursive directory tree. diff --git a/leap0/git.py b/leap0/git.py index 975ebea..4c5fd28 100644 --- a/leap0/git.py +++ b/leap0/git.py @@ -3,8 +3,9 @@ from typing import Any from ._transport import Transport -from ._types import GitCommitResponseDict, GitResultDict -from .models import GitCommitResult, GitResult, SandboxRef, sandbox_id_of +from ._utils.errors import intercept_errors +from .common.git import GitCommitResponseDict, GitCommitResult, GitResult, GitResultDict +from .common.sandbox import SandboxRef, sandbox_id_of class GitClient: @@ -19,6 +20,7 @@ def _git_result(self, path: str, payload: dict[str, Any]) -> GitResult: data: GitResultDict = self._transport.request_json("POST", path, json=payload) # type: ignore[assignment] return GitResult.from_dict(data) + @intercept_errors("Failed to clone repository: ") def clone( self, sandbox: SandboxRef, @@ -56,10 +58,12 @@ def clone( payload["password"] = password return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/clone", payload) + @intercept_errors("Failed to get git status: ") def status(self, sandbox: SandboxRef, *, path: str) -> GitResult: """Get the current repository status (porcelain v2 format).""" return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/status", {"path": path}) + @intercept_errors("Failed to list branches: ") def branches( self, sandbox: SandboxRef, @@ -85,6 +89,7 @@ def branches( payload["not_contains"] = not_contains return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/branches", payload) + @intercept_errors("Failed to get unstaged diff: ") def diff_unstaged(self, sandbox: SandboxRef, *, path: str, context_lines: int | None = None) -> GitResult: """Show working tree changes that are not staged yet.""" payload: dict[str, Any] = {"path": path} @@ -92,6 +97,7 @@ def diff_unstaged(self, sandbox: SandboxRef, *, path: str, context_lines: int | payload["context_lines"] = context_lines return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff-unstaged", payload) + @intercept_errors("Failed to get staged diff: ") def diff_staged(self, sandbox: SandboxRef, *, path: str, context_lines: int | None = None) -> GitResult: """Show changes that are already staged for the next commit.""" payload: dict[str, Any] = {"path": path} @@ -99,6 +105,7 @@ def diff_staged(self, sandbox: SandboxRef, *, path: str, context_lines: int | No payload["context_lines"] = context_lines return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff-staged", payload) + @intercept_errors("Failed to get diff: ") def diff(self, sandbox: SandboxRef, *, path: str, target: str, context_lines: int | None = None) -> GitResult: """Compare the current state against a branch, tag, or commit.""" payload: dict[str, Any] = {"path": path, "target": target} @@ -106,10 +113,12 @@ def diff(self, sandbox: SandboxRef, *, path: str, target: str, context_lines: in payload["context_lines"] = context_lines return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff", payload) + @intercept_errors("Failed to reset: ") def reset(self, sandbox: SandboxRef, *, path: str) -> GitResult: """Unstage all currently staged changes.""" return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/reset", {"path": path}) + @intercept_errors("Failed to get git log: ") def log( self, sandbox: SandboxRef, @@ -137,10 +146,12 @@ def log( payload["end_timestamp"] = end_timestamp return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/log", payload) + @intercept_errors("Failed to show revision: ") def show(self, sandbox: SandboxRef, *, path: str, revision: str = "HEAD") -> GitResult: """Show the full output for a commit, branch, or tag revision.""" return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/show", {"path": path, "revision": revision}) + @intercept_errors("Failed to create branch: ") def create_branch( self, sandbox: SandboxRef, @@ -166,6 +177,7 @@ def create_branch( payload["base_branch"] = base_branch return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/create-branch", payload) + @intercept_errors("Failed to checkout branch: ") def checkout_branch(self, sandbox: SandboxRef, *, path: str, branch: str, create: bool | None = None) -> GitResult: """Switch to an existing branch. Set *create* to create it if it does not exist.""" payload: dict[str, Any] = {"path": path, "branch": branch} @@ -173,14 +185,17 @@ def checkout_branch(self, sandbox: SandboxRef, *, path: str, branch: str, create payload["create"] = create return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/checkout-branch", payload) + @intercept_errors("Failed to delete branch: ") def delete_branch(self, sandbox: SandboxRef, *, path: str, name: str, force: bool = False) -> GitResult: """Delete a branch. Set *force* to delete even if unmerged.""" return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/delete-branch", {"path": path, "name": name, "force": force}) + @intercept_errors("Failed to stage files: ") def add(self, sandbox: SandboxRef, *, path: str, files: list[str]) -> GitResult: """Stage files for the next commit.""" return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/add", {"path": path, "files": files}) + @intercept_errors("Failed to commit: ") def commit( self, sandbox: SandboxRef, @@ -215,6 +230,7 @@ def commit( ) return GitCommitResult.from_dict(data) + @intercept_errors("Failed to push: ") def push( self, sandbox: SandboxRef, @@ -250,6 +266,7 @@ def push( payload["password"] = password return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/push", payload) + @intercept_errors("Failed to pull: ") def pull( self, sandbox: SandboxRef, @@ -258,6 +275,7 @@ def pull( remote: str | None = None, branch: str | None = None, rebase: bool | None = None, + set_upstream: bool | None = None, username: str | None = None, password: str | None = None, ) -> GitResult: @@ -269,6 +287,7 @@ def pull( remote: Remote name (default ``"origin"``). branch: Branch name. rebase: Rebase instead of merge. + set_upstream: Set upstream tracking. username: Auth username. password: Auth password or token. """ @@ -279,6 +298,8 @@ def pull( payload["branch"] = branch if rebase is not None: payload["rebase"] = rebase + if set_upstream is not None: + payload["set_upstream"] = set_upstream if username is not None: payload["username"] = username if password is not None: diff --git a/leap0/lsp.py b/leap0/lsp.py index d2039a4..b572634 100644 --- a/leap0/lsp.py +++ b/leap0/lsp.py @@ -3,9 +3,10 @@ from typing import Any from ._transport import Transport -from ._types import LspSuccessResponseDict -from ._utils import file_uri as _file_uri -from .models import LspResponse, SandboxRef, sandbox_id_of +from ._utils.errors import intercept_errors +from ._utils.url import file_uri as _file_uri +from .common.lsp import LspResponse, LspSuccessResponseDict +from .common.sandbox import SandboxRef, sandbox_id_of class LspClient: @@ -19,6 +20,7 @@ class LspClient: def __init__(self, transport: Transport): self._transport = transport + @intercept_errors("Failed to start LSP server: ") def start(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str) -> LspResponse: """Start the LSP server for a language and project. @@ -33,11 +35,13 @@ def start(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str) data: LspSuccessResponseDict = self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/lsp/start", json={"language_id": language_id, "path_to_project": path_to_project}) # type: ignore[assignment] return LspResponse.from_dict(data) + @intercept_errors("Failed to stop LSP server: ") def stop(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str) -> LspResponse: """Send ``shutdown`` and ``exit`` to the language server and terminate it.""" data: LspSuccessResponseDict = self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/lsp/stop", json={"language_id": language_id, "path_to_project": path_to_project}) # type: ignore[assignment] return LspResponse.from_dict(data) + @intercept_errors("Failed to open document: ") def did_open( self, sandbox: SandboxRef, @@ -75,6 +79,7 @@ def did_open( expected_status=204, ) + @intercept_errors("Failed to open document: ") def did_open_path( self, sandbox: SandboxRef, @@ -88,6 +93,7 @@ def did_open_path( """Like :meth:`did_open` but accepts a file path instead of a URI.""" self.did_open(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path), text=text, version=version) + @intercept_errors("Failed to close document: ") def did_close(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str, uri: str) -> None: """Notify the language server that a document was closed.""" self._transport.request( @@ -97,10 +103,12 @@ def did_close(self, sandbox: SandboxRef, *, language_id: str, path_to_project: s expected_status=204, ) + @intercept_errors("Failed to close document: ") def did_close_path(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str, path: str) -> None: """Like :meth:`did_close` but accepts a file path instead of a URI.""" self.did_close(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path)) + @intercept_errors("Failed to get completions: ") def completions( self, sandbox: SandboxRef, @@ -123,6 +131,7 @@ def completions( }, ) + @intercept_errors("Failed to get completions: ") def completions_path( self, sandbox: SandboxRef, @@ -136,6 +145,7 @@ def completions_path( """Like :meth:`completions` but accepts a file path instead of a URI.""" return self.completions(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path), line=line, character=character) + @intercept_errors("Failed to get document symbols: ") def document_symbols(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str, uri: str) -> dict[str, Any]: """Returns the raw JSON-RPC 2.0 response from the language server.""" return self._transport.request_json( @@ -144,6 +154,7 @@ def document_symbols(self, sandbox: SandboxRef, *, language_id: str, path_to_pro json={"language_id": language_id, "path_to_project": path_to_project, "uri": uri}, ) + @intercept_errors("Failed to get document symbols: ") def document_symbols_path(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str, path: str) -> dict[str, Any]: """Like :meth:`document_symbols` but accepts a file path instead of a URI.""" return self.document_symbols(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path)) diff --git a/leap0/models.py b/leap0/models.py deleted file mode 100644 index 38133c8..0000000 --- a/leap0/models.py +++ /dev/null @@ -1,770 +0,0 @@ -from __future__ import annotations - -import base64 -from dataclasses import dataclass, field -from typing import Any, Literal, cast - -from websockets.sync.client import ClientConnection - -from ._types import ( - CodeContextDict, - CodeExecutionOutputDict, - CodeExecutionResultDict, - EditFileResponseDict, - EditResultDict, - ExecutionErrorDict, - ExecutionLogsDict, - FileInfoDict, - GitCommitResponseDict, - GitResultDict, - ImageConfigDict, - LsResponseDict, - LspSuccessResponseDict, - NetworkPolicyDict, - ProcessResultDict, - PtySessionInfoDict, - SandboxCreateResponseDict, - SandboxState, - SandboxStatusResponseDict, - SearchMatchDict, - SnapshotCreateResponseDict, - SshAccessValidationDict, - SshCreateAccessDict, - StreamEventDict, - TreeEntryDict, - TreeResponseDict, - UploadTemplateResponseDict, - DesktopDisplayInfoDict, - DesktopHealthDict, - DesktopProcessErrorsDict, - DesktopProcessLogsDict, - DesktopProcessRestartDict, - DesktopProcessStatusDict, - DesktopProcessStatusListDict, - DesktopPointerPositionDict, - DesktopRecordingStatusDict, - DesktopRecordingSummaryDict, - DesktopWindowDict, -) - - -@dataclass(slots=True) -class Sandbox: - id: str - template_id: str = "" - vcpu: int = 0 - memory_mib: int = 0 - disk_mib: int = 0 - state: SandboxState = "starting" - auto_pause: bool = False - created_at: str = "" - network_policy: NetworkPolicyDict | None = None - - @classmethod - def from_dict(cls, data: SandboxCreateResponseDict) -> Sandbox: - state = data.get("state", "starting") - return cls( - id=data["id"], - template_id=data.get("template_id", ""), - vcpu=int(data.get("vcpu", 0)), - memory_mib=int(data.get("memory_mib", 0)), - disk_mib=int(data.get("disk_mib", 0)), - state=state, # type: ignore[arg-type] - auto_pause=bool(data.get("auto_pause", False)), - created_at=data.get("created_at", ""), - network_policy=data.get("network_policy"), - ) - - -@dataclass(slots=True) -class SandboxStatus: - id: str - template_id: str - vcpu: int - memory_mib: int - disk_mib: int - state: SandboxState - auto_pause: bool - created_at: str - - @classmethod - def from_dict(cls, data: SandboxStatusResponseDict) -> SandboxStatus: - state = data.get("state", "starting") - return cls( - id=data.get("id", ""), - template_id=data.get("template_id", ""), - vcpu=int(data.get("vcpu", 0)), - memory_mib=int(data.get("memory_mib", 0)), - disk_mib=int(data.get("disk_mib", 0)), - state=state, # type: ignore[arg-type] - auto_pause=bool(data.get("auto_pause", False)), - created_at=data.get("created_at", ""), - ) - - -SandboxRef = str | Sandbox | SandboxStatus - - -def sandbox_id_of(value: SandboxRef) -> str: - if isinstance(value, str): - return value - return value.id - - -@dataclass(slots=True) -class Snapshot: - snapshot_id: str - name: str - template_id: str = "" - vcpu: int = 0 - memory_mib: int = 0 - disk_mib: int = 0 - network_policy: NetworkPolicyDict | None = None - created_at: str = "" - - @property - def id(self) -> str: - return self.snapshot_id - - @classmethod - def from_dict(cls, data: SnapshotCreateResponseDict) -> Snapshot: - return cls( - snapshot_id=data.get("snapshot_id", ""), - name=data.get("name", ""), - template_id=data.get("template_id", ""), - vcpu=int(data.get("vcpu", 0)), - memory_mib=int(data.get("memory_mib", 0)), - disk_mib=int(data.get("disk_mib", 0)), - network_policy=data.get("network_policy"), - created_at=data.get("created_at", ""), - ) - - -SnapshotRef = str | Snapshot - - -def snapshot_id_of(value: SnapshotRef) -> str: - if isinstance(value, str): - return value - return value.snapshot_id - - -@dataclass(slots=True) -class FileInfo: - name: str - path: str - is_dir: bool = False - size: int = 0 - mode: str = "" - mtime: int = 0 - owner: str = "" - group: str = "" - is_symlink: bool = False - link_target: str = "" - - @classmethod - def from_dict(cls, data: FileInfoDict) -> FileInfo: - return cls( - name=data.get("name", ""), - path=data.get("path", ""), - is_dir=bool(data.get("is_dir", False)), - size=int(data.get("size", 0)), - mode=data.get("mode", ""), - mtime=int(data.get("mtime", 0)), - owner=data.get("owner", ""), - group=data.get("group", ""), - is_symlink=bool(data.get("is_symlink", False)), - link_target=data.get("link_target", ""), - ) - - -@dataclass(slots=True) -class LsResult: - items: list[FileInfo] - - @classmethod - def from_dict(cls, data: LsResponseDict) -> LsResult: - return cls(items=[FileInfo.from_dict(item) for item in data.get("items", [])]) - - -@dataclass(slots=True) -class FileEdit: - find: str - replace: str = "" - - def to_dict(self) -> dict[str, str]: - return {"find": self.find, "replace": self.replace} - - -@dataclass(slots=True) -class EditFileResult: - diff: str = "" - replacements: int = 0 - - @classmethod - def from_dict(cls, data: EditFileResponseDict) -> EditFileResult: - return cls( - diff=data.get("diff", ""), - replacements=int(data.get("replacements", 0)), - ) - - -@dataclass(slots=True) -class EditResult: - file: str = "" - success: bool = False - error: str = "" - - @classmethod - def from_dict(cls, data: EditResultDict) -> EditResult: - return cls( - file=data.get("file", ""), - success=bool(data.get("success", False)), - error=data.get("error", ""), - ) - - -@dataclass(slots=True) -class SearchMatch: - path: str = "" - line: int = 0 - content: str = "" - - @classmethod - def from_dict(cls, data: SearchMatchDict) -> SearchMatch: - return cls( - path=data.get("path", ""), - line=int(data.get("line", 0)), - content=data.get("content", ""), - ) - - -@dataclass(slots=True) -class TreeEntry: - name: str - type: str = "file" - children: list[TreeEntry] = field(default_factory=list) - - @classmethod - def from_dict(cls, data: TreeEntryDict) -> TreeEntry: - return cls( - name=data.get("name", ""), - type=data.get("type", "file"), - children=[TreeEntry.from_dict(c) for c in data.get("children", [])], - ) - - -@dataclass(slots=True) -class TreeResult: - items: list[TreeEntry] - - @classmethod - def from_dict(cls, data: TreeResponseDict) -> TreeResult: - return cls(items=[TreeEntry.from_dict(item) for item in data.get("items", [])]) - - -@dataclass(slots=True) -class GitResult: - output: str - exit_code: int - - @classmethod - def from_dict(cls, data: GitResultDict) -> GitResult: - return cls(output=data.get("output", ""), exit_code=int(data.get("exit_code", 0))) - - -@dataclass(slots=True) -class GitCommitResult: - sha: str | None - result: GitResult | None - - @classmethod - def from_dict(cls, data: GitCommitResponseDict) -> GitCommitResult: - result_data = data.get("result") - return cls( - sha=data.get("sha"), - result=GitResult.from_dict(result_data) if isinstance(result_data, dict) else None, - ) - - -@dataclass(slots=True) -class ProcessResult: - exit_code: int - result: str - - @classmethod - def from_dict(cls, data: ProcessResultDict) -> ProcessResult: - return cls(exit_code=int(data.get("exit_code", 0)), result=data.get("result", "")) - - -@dataclass(slots=True) -class SshAccess: - id: str - password: str - ssh_command: str - sandbox_id: str = "" - expires_at: str = "" - created_at: str = "" - updated_at: str = "" - - @classmethod - def from_dict(cls, data: SshCreateAccessDict) -> SshAccess: - return cls( - id=data.get("id", ""), - password=data.get("password", ""), - ssh_command=data.get("ssh_command", ""), - sandbox_id=data.get("sandbox_id", ""), - expires_at=data.get("expires_at", ""), - created_at=data.get("created_at", ""), - updated_at=data.get("updated_at", ""), - ) - - -@dataclass(slots=True) -class SshValidation: - valid: bool - sandbox_id: str - - @classmethod - def from_dict(cls, data: SshAccessValidationDict) -> SshValidation: - return cls( - valid=bool(data.get("valid", False)), - sandbox_id=data.get("sandbox_id", ""), - ) - - -@dataclass(slots=True) -class PtySession: - id: str = "" - cwd: str = "" - envs: dict[str, str] = field(default_factory=dict) - cols: int = 0 - rows: int = 0 - created_at: str = "" - active: bool = False - lazy_start: bool = False - - @classmethod - def from_dict(cls, data: PtySessionInfoDict) -> PtySession: - return cls( - id=data.get("id", data.get("session_id", "")), - cwd=data.get("cwd", ""), - envs=data.get("envs") or {}, - cols=int(data.get("cols", 0)), - rows=int(data.get("rows", 0)), - created_at=data.get("created_at", ""), - active=bool(data.get("active", False)), - lazy_start=bool(data.get("lazy_start", False)), - ) - - -@dataclass(slots=True) -class PtyConnection: - websocket: ClientConnection - - def send(self, data: str | bytes) -> None: - payload = data.encode() if isinstance(data, str) else data - self.websocket.send(payload) - - def recv(self) -> bytes: - message = self.websocket.recv() - if isinstance(message, str): - return message.encode() - if isinstance(message, bytes): - return message - return b"".join(message) - - def close(self) -> None: - self.websocket.close() - - -@dataclass(slots=True) -class LspResponse: - success: bool = False - - @classmethod - def from_dict(cls, data: LspSuccessResponseDict) -> LspResponse: - return cls(success=bool(data.get("success", False))) - - -@dataclass(slots=True) -class ImageConfig: - entrypoint: list[str] = field(default_factory=list) - cmd: list[str] = field(default_factory=list) - working_dir: str | None = None - user: str = "" - env: dict[str, Any] | None = None - - @classmethod - def from_dict(cls, data: ImageConfigDict) -> ImageConfig: - return cls( - entrypoint=data.get("entrypoint") or [], - cmd=data.get("cmd") or [], - working_dir=data.get("working_dir"), - user=data.get("user", ""), - env=data.get("env"), - ) - - -@dataclass(slots=True) -class Template: - id: str - name: str - digest: str = "" - image_config: ImageConfig | None = None - is_system: bool = False - created_at: str = "" - - @classmethod - def from_dict(cls, data: UploadTemplateResponseDict) -> Template: - ic = data.get("image_config") - return cls( - id=data.get("id", ""), # type: ignore[arg-type] - name=data.get("name", ""), # type: ignore[arg-type] - digest=data.get("digest", ""), # type: ignore[arg-type] - image_config=ImageConfig.from_dict(ic) if isinstance(ic, dict) else None, - is_system=bool(data.get("is_system", False)), # type: ignore[arg-type] - created_at=data.get("created_at", ""), # type: ignore[arg-type] - ) - - -_LANGUAGE_INT_TO_STR: dict[int, str] = {1: "python", 2: "typescript"} - - -def _normalize_language(value: int | str | None) -> str: - """Accept both the server's integer enum (1=python, 2=typescript) and plain strings.""" - if isinstance(value, int): - return _LANGUAGE_INT_TO_STR.get(value, str(value)) - return str(value) if value else "" - - -@dataclass(slots=True) -class CodeExecutionOutput: - is_primary: bool = False - text: str | None = None - png: str | None = None - svg: str | None = None - html: str | None = None - markdown: str | None = None - json_data: dict[str, Any] | None = None - jpeg: str | None = None - pdf: str | None = None - latex: str | None = None - javascript: str | None = None - extra: dict[str, Any] | None = None - - @classmethod - def from_dict(cls, data: CodeExecutionOutputDict) -> CodeExecutionOutput: - return cls( - is_primary=bool(data.get("is_primary", data.get("is_main_result", False))), - text=data.get("text"), - png=data.get("png"), - svg=data.get("svg"), - html=data.get("html"), - markdown=data.get("markdown"), - json_data=data.get("json"), - jpeg=data.get("jpeg"), - pdf=data.get("pdf"), - latex=data.get("latex"), - javascript=data.get("javascript"), - extra=data.get("extra"), - ) - - @property - def is_main_result(self) -> bool: - return self.is_primary - - def png_bytes(self) -> bytes | None: - return base64.b64decode(self.png) if self.png else None - - def jpeg_bytes(self) -> bytes | None: - return base64.b64decode(self.jpeg) if self.jpeg else None - - def pdf_bytes(self) -> bytes | None: - return base64.b64decode(self.pdf) if self.pdf else None - - -@dataclass(slots=True) -class CodeExecutionError: - name: str - value: str - traceback: str - - @classmethod - def from_dict(cls, data: ExecutionErrorDict) -> CodeExecutionError: - return cls( - name=data.get("name", ""), - value=data.get("value", ""), - traceback=data.get("traceback", ""), - ) - - -@dataclass(slots=True) -class ExecutionLogs: - stdout: list[str] = field(default_factory=list) - stderr: list[str] = field(default_factory=list) - - @classmethod - def from_dict(cls, data: ExecutionLogsDict) -> ExecutionLogs: - return cls( - stdout=data.get("stdout") or [], - stderr=data.get("stderr") or [], - ) - - -@dataclass(slots=True) -class CodeExecutionResult: - items: list[CodeExecutionOutput] - logs: ExecutionLogs - error: CodeExecutionError | None - execution_count: int | None - context_id: str | None = None - - @classmethod - def from_dict(cls, data: CodeExecutionResultDict, *, context_id: str | None = None) -> CodeExecutionResult: - error = data.get("error") - logs_data = data.get("logs", {}) - return cls( - items=[CodeExecutionOutput.from_dict(item) for item in data.get("items", [])], - logs=ExecutionLogs.from_dict(logs_data) if logs_data else ExecutionLogs(), # type: ignore[arg-type] - error=CodeExecutionError.from_dict(error) if isinstance(error, dict) else None, - execution_count=data.get("execution_count"), - context_id=context_id, - ) - - @property - def main_text(self) -> str | None: - for result in self.items: - if result.is_primary: - return result.text - return self.items[-1].text if self.items else None - - -_STREAM_TYPE_INT_TO_STR: dict[int, str] = {0: "stdout", 1: "stderr", 2: "exit", 3: "error"} - -StreamEventType = Literal["stdout", "stderr", "exit", "error"] - - -@dataclass(slots=True) -class StreamEvent: - type: str - data: str = "" - code: int | None = None - - @classmethod - def from_dict(cls, data: StreamEventDict) -> StreamEvent: - raw_type = data.get("type", "") - if isinstance(raw_type, int): - event_type = _STREAM_TYPE_INT_TO_STR.get(raw_type, str(raw_type)) - else: - event_type = str(raw_type) - return cls( - type=event_type, - data=data.get("data", ""), - code=data.get("code"), - ) - - -@dataclass(slots=True) -class CodeContext: - id: str - language: str = "" - cwd: str = "" - - @classmethod - def from_dict(cls, data: CodeContextDict) -> CodeContext: - return cls( - id=data.get("id", data.get("context_id", "")), - language=_normalize_language(data.get("language")), - cwd=data.get("cwd", ""), - ) - - -@dataclass(slots=True) -class DesktopDisplayInfo: - display: str = "" - width: int = 0 - height: int = 0 - - @classmethod - def from_dict(cls, data: DesktopDisplayInfoDict) -> DesktopDisplayInfo: - return cls( - display=data.get("display", ""), - width=int(data.get("width", 0)), - height=int(data.get("height", 0)), - ) - - -@dataclass(slots=True) -class DesktopWindow: - id: str = "" - desktop: int = 0 - pid: int = 0 - x: int = 0 - y: int = 0 - width: int = 0 - height: int = 0 - window_class: str = "" - host: str = "" - title: str = "" - focused: bool = False - - @classmethod - def from_dict(cls, data: DesktopWindowDict) -> DesktopWindow: - return cls( - id=data.get("id", ""), - desktop=int(data.get("desktop", 0)), - pid=int(data.get("pid", 0)), - x=int(data.get("x", 0)), - y=int(data.get("y", 0)), - width=int(data.get("width", 0)), - height=int(data.get("height", 0)), - window_class=data.get("class", data.get("class_", "")), - host=data.get("host", ""), - title=data.get("title", ""), - focused=bool(data.get("focused", False)), - ) - - -@dataclass(slots=True) -class DesktopPointerPosition: - x: int = 0 - y: int = 0 - - @classmethod - def from_dict(cls, data: DesktopPointerPositionDict) -> DesktopPointerPosition: - return cls(x=int(data.get("x", 0)), y=int(data.get("y", 0))) - - -@dataclass(slots=True) -class DesktopRecordingStatus: - id: str = "" - active: bool = False - path: str = "" - started_at: str = "" - stopped_at: str = "" - download: str = "" - mime_type: str = "" - file_name: str = "" - display: str = "" - resolution: str = "" - - @classmethod - def from_dict(cls, data: DesktopRecordingStatusDict) -> DesktopRecordingStatus: - return cls( - id=data.get("id", ""), - active=bool(data.get("active", False)), - path=data.get("path", ""), - started_at=data.get("started_at", ""), - stopped_at=data.get("stopped_at", ""), - download=data.get("download", ""), - mime_type=data.get("mime_type", ""), - file_name=data.get("file_name", ""), - display=data.get("display", ""), - resolution=data.get("resolution", ""), - ) - - -@dataclass(slots=True) -class DesktopRecordingSummary: - id: str = "" - file_name: str = "" - path: str = "" - download: str = "" - mime_type: str = "" - size_bytes: int = 0 - created_at: str = "" - active: bool = False - - @classmethod - def from_dict(cls, data: DesktopRecordingSummaryDict) -> DesktopRecordingSummary: - return cls( - id=data.get("id", ""), - file_name=data.get("file_name", ""), - path=data.get("path", ""), - download=data.get("download", ""), - mime_type=data.get("mime_type", ""), - size_bytes=int(data.get("size_bytes", 0)), - created_at=data.get("created_at", ""), - active=bool(data.get("active", False)), - ) - - -@dataclass(slots=True) -class DesktopHealth: - ok: bool = False - state: str = "" - - @classmethod - def from_dict(cls, data: DesktopHealthDict) -> DesktopHealth: - return cls(ok=bool(data.get("ok", False)), state=data.get("state", "")) - - -@dataclass(slots=True) -class DesktopProcessStatus: - name: str = "" - running: bool = False - pid: int = 0 - stdout_log: str = "" - stderr_log: str = "" - - @classmethod - def from_dict(cls, data: DesktopProcessStatusDict) -> DesktopProcessStatus: - return cls( - name=data.get("name", ""), - running=bool(data.get("running", False)), - pid=int(data.get("pid", 0)), - stdout_log=data.get("stdout_log", ""), - stderr_log=data.get("stderr_log", ""), - ) - - -@dataclass(slots=True) -class DesktopProcessStatusList: - status: str = "" - items: list[DesktopProcessStatus] = field(default_factory=list) - running: int = 0 - total: int = 0 - - @classmethod - def from_dict(cls, data: DesktopProcessStatusListDict) -> DesktopProcessStatusList: - return cls( - status=data.get("status", ""), - items=[DesktopProcessStatus.from_dict(cast(DesktopProcessStatusDict, cast(object, item))) for item in data.get("items", [])], - running=int(data.get("running", 0)), - total=int(data.get("total", 0)), - ) - - -@dataclass(slots=True) -class DesktopProcessRestart: - message: str = "" - status: DesktopProcessStatus | None = None - - @classmethod - def from_dict(cls, data: DesktopProcessRestartDict) -> DesktopProcessRestart: - status = data.get("status") - return cls( - message=data.get("message", ""), - status=DesktopProcessStatus.from_dict(cast(DesktopProcessStatusDict, cast(object, status))) if isinstance(status, dict) else None, - ) - - -@dataclass(slots=True) -class DesktopProcessLogs: - process: str = "" - logs: str = "" - - @classmethod - def from_dict(cls, data: DesktopProcessLogsDict) -> DesktopProcessLogs: - return cls(process=data.get("process", ""), logs=data.get("logs", "")) - - -@dataclass(slots=True) -class DesktopProcessErrors: - process: str = "" - errors: str = "" - - @classmethod - def from_dict(cls, data: DesktopProcessErrorsDict) -> DesktopProcessErrors: - return cls(process=data.get("process", ""), errors=data.get("errors", "")) diff --git a/leap0/process.py b/leap0/process.py index e3bcbe1..45cac51 100644 --- a/leap0/process.py +++ b/leap0/process.py @@ -3,8 +3,9 @@ from typing import Any from ._transport import Transport -from ._types import ProcessResultDict -from .models import ProcessResult, SandboxRef, sandbox_id_of +from ._utils.errors import intercept_errors +from .common.process import ProcessResult, ProcessResultDict +from .common.sandbox import SandboxRef, sandbox_id_of class ProcessClient: @@ -13,6 +14,7 @@ class ProcessClient: def __init__(self, transport: Transport): self._transport = transport + @intercept_errors("Failed to execute command: ") def execute(self, sandbox: SandboxRef, *, command: str, cwd: str | None = None, timeout: int | None = None) -> ProcessResult: """Run a shell command and wait for the result. diff --git a/leap0/pty.py b/leap0/pty.py index 5ecf6ec..5056911 100644 --- a/leap0/pty.py +++ b/leap0/pty.py @@ -5,10 +5,11 @@ from websockets.sync.client import connect from ._transport import Transport -from ._types import PtyListResponseDict, PtySessionInfoDict -from ._utils import websocket_url_from_http -from .exceptions import Leap0WebSocketError -from .models import PtyConnection, PtySession, SandboxRef, sandbox_id_of +from ._utils.errors import intercept_errors +from ._utils.url import websocket_url_from_http +from .common.errors import Leap0WebSocketError +from .common.pty import PtyConnection, PtyListResponseDict, PtySession, PtySessionInfoDict +from .common.sandbox import SandboxRef, sandbox_id_of class PtyClient: @@ -21,11 +22,13 @@ class PtyClient: def __init__(self, transport: Transport): self._transport = transport + @intercept_errors("Failed to list PTY sessions: ") def list(self, sandbox: SandboxRef) -> list[PtySession]: """List all PTY sessions for a sandbox.""" data: PtyListResponseDict = self._transport.request_json("GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty") # type: ignore[assignment] return [PtySession.from_dict(item) for item in data.get("items", [])] + @intercept_errors("Failed to create PTY session: ") def create( self, sandbox: SandboxRef, @@ -64,15 +67,18 @@ def create( data: PtySessionInfoDict = self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty", json=payload, expected_status=201) # type: ignore[assignment] return PtySession.from_dict(data) + @intercept_errors("Failed to get PTY session: ") def get(self, sandbox: SandboxRef, session_id: str) -> PtySession: """Get details for a single PTY session.""" data: PtySessionInfoDict = self._transport.request_json("GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}") # type: ignore[assignment] return PtySession.from_dict(data) + @intercept_errors("Failed to delete PTY session: ") def delete(self, sandbox: SandboxRef, session_id: str) -> None: """Kill the shell process and remove the session.""" self._transport.request("DELETE", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}", expected_status=204) + @intercept_errors("Failed to resize PTY session: ") def resize(self, sandbox: SandboxRef, session_id: str, *, cols: int, rows: int) -> PtySession: """Change the terminal dimensions while connected.""" data: PtySessionInfoDict = self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}/resize", json={"cols": cols, "rows": rows}) # type: ignore[assignment] @@ -82,6 +88,7 @@ def websocket_url(self, sandbox: SandboxRef, session_id: str) -> str: """Build the WSS URL for connecting to a PTY session.""" return websocket_url_from_http(f"{self._transport.base_url}/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}/connect") + @intercept_errors("Failed to connect to PTY session: ") def connect(self, sandbox: SandboxRef, session_id: str, **kwargs: Any) -> PtyConnection: """Open a WebSocket connection for interactive terminal I/O. diff --git a/leap0/sandboxes.py b/leap0/sandboxes.py index 0e04f61..743a2f6 100644 --- a/leap0/sandboxes.py +++ b/leap0/sandboxes.py @@ -4,10 +4,13 @@ from typing import Any from ._transport import Transport -from ._types import NetworkPolicyDict, SandboxCreateResponseDict, SandboxStatusResponseDict -from ._utils import ensure_leading_slash, sandbox_base_url, websocket_url_from_http -from .constants import DEFAULT_MEMORY_MIB, DEFAULT_TEMPLATE_NAME, DEFAULT_TIMEOUT_MIN, DEFAULT_VCPU -from .models import Sandbox, SandboxRef, SandboxStatus, sandbox_id_of +from ._utils.errors import intercept_errors +from ._utils.url import ensure_leading_slash, sandbox_base_url, websocket_url_from_http +from .common.config import DEFAULT_MEMORY_MIB, DEFAULT_TEMPLATE_NAME, DEFAULT_TIMEOUT_MIN, DEFAULT_VCPU +from .common.sandbox import ( + NetworkPolicyDict, Sandbox, SandboxCreateResponseDict, SandboxRef, + SandboxStatus, SandboxStatusResponseDict, sandbox_id_of, +) _OTEL_ENV_KEYS = ( @@ -35,6 +38,7 @@ def __init__(self, transport: Transport, *, sandbox_domain: str | None = None): self._transport = transport self._sandbox_domain = sandbox_domain.strip("/") if sandbox_domain else None + @intercept_errors("Failed to create sandbox: ") def create( self, *, @@ -79,6 +83,7 @@ def create( ) return Sandbox.from_dict(data) + @intercept_errors("Failed to pause sandbox: ") def pause(self, sandbox: SandboxRef) -> Sandbox: """Pause a running sandbox. The sandbox must be in the ``running`` state.""" data: SandboxCreateResponseDict = self._transport.request_json( # type: ignore[assignment] @@ -86,6 +91,7 @@ def pause(self, sandbox: SandboxRef) -> Sandbox: ) return Sandbox.from_dict(data) + @intercept_errors("Failed to get sandbox: ") def get(self, sandbox: SandboxRef) -> SandboxStatus: """Get the current status of a sandbox.""" data: SandboxStatusResponseDict = self._transport.request_json( # type: ignore[assignment] @@ -93,6 +99,7 @@ def get(self, sandbox: SandboxRef) -> SandboxStatus: ) return SandboxStatus.from_dict(data) + @intercept_errors("Failed to delete sandbox: ") def delete(self, sandbox: SandboxRef) -> None: """Terminate and delete a sandbox.""" self._transport.request("DELETE", f"/v1/sandbox/{sandbox_id_of(sandbox)}/", expected_status=204) diff --git a/leap0/snapshots.py b/leap0/snapshots.py index 1d3517e..81b2c9c 100644 --- a/leap0/snapshots.py +++ b/leap0/snapshots.py @@ -3,8 +3,9 @@ from typing import Any from ._transport import Transport -from ._types import NetworkPolicyDict, SandboxCreateResponseDict, SnapshotCreateResponseDict -from .models import Sandbox, SandboxRef, Snapshot, SnapshotRef, sandbox_id_of, snapshot_id_of +from ._utils.errors import intercept_errors +from .common.sandbox import NetworkPolicyDict, Sandbox, SandboxCreateResponseDict, SandboxRef, sandbox_id_of +from .common.snapshot import Snapshot, SnapshotCreateResponseDict, SnapshotRef, snapshot_id_of class SnapshotsClient: @@ -17,6 +18,7 @@ class SnapshotsClient: def __init__(self, transport: Transport): self._transport = transport + @intercept_errors("Failed to create snapshot: ") def create( self, sandbox: SandboxRef, @@ -40,6 +42,7 @@ def create( ) return Snapshot.from_dict(data) + @intercept_errors("Failed to pause sandbox: ") def pause( self, sandbox: SandboxRef, @@ -65,6 +68,7 @@ def pause( ) return Snapshot.from_dict(data) + @intercept_errors("Failed to resume snapshot: ") def resume( self, *, @@ -97,6 +101,7 @@ def resume( ) return Sandbox.from_dict(data) + @intercept_errors("Failed to delete snapshot: ") def delete(self, snapshot: SnapshotRef) -> None: """Delete a snapshot.""" self._transport.request("DELETE", f"/v1/snapshot/{snapshot_id_of(snapshot)}", expected_status=204) diff --git a/leap0/ssh.py b/leap0/ssh.py index 8e75ff8..f7ccecc 100644 --- a/leap0/ssh.py +++ b/leap0/ssh.py @@ -1,7 +1,9 @@ from __future__ import annotations from ._transport import Transport -from .models import SandboxRef, SshAccess, SshValidation, sandbox_id_of +from ._utils.errors import intercept_errors +from .common.sandbox import SandboxRef, sandbox_id_of +from .common.ssh import SshAccess, SshValidation class SshClient: @@ -15,6 +17,7 @@ class SshClient: def __init__(self, transport: Transport): self._transport = transport + @intercept_errors("Failed to create SSH access: ") def create_access(self, sandbox: SandboxRef) -> SshAccess: """Generate SSH credentials for a sandbox. @@ -24,10 +27,12 @@ def create_access(self, sandbox: SandboxRef) -> SshAccess: data = self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/ssh/access", expected_status=201) return SshAccess.from_dict(data) # type: ignore[arg-type] + @intercept_errors("Failed to delete SSH access: ") def delete_access(self, sandbox: SandboxRef) -> None: """Revoke SSH access for a sandbox. The credential is invalidated immediately.""" self._transport.request("DELETE", f"/v1/sandbox/{sandbox_id_of(sandbox)}/ssh/access", expected_status=204) + @intercept_errors("Failed to validate SSH access: ") def validate_access(self, sandbox: SandboxRef, *, access_id: str, password: str) -> SshValidation: """Check whether an SSH access credential is still valid and not expired.""" data = self._transport.request_json( @@ -37,6 +42,7 @@ def validate_access(self, sandbox: SandboxRef, *, access_id: str, password: str) ) return SshValidation.from_dict(data) # type: ignore[arg-type] + @intercept_errors("Failed to regenerate SSH access: ") def regenerate_access(self, sandbox: SandboxRef) -> SshAccess: """Invalidate the current credential and generate a new one. The expiry is also reset.""" data = self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/ssh/regen") diff --git a/leap0/templates.py b/leap0/templates.py index 6dfc19e..8f92081 100644 --- a/leap0/templates.py +++ b/leap0/templates.py @@ -3,8 +3,8 @@ from typing import Any from ._transport import Transport -from ._types import RegistryCredentialsDict, UploadTemplateResponseDict -from .models import Template +from ._utils.errors import intercept_errors +from .common.template import RegistryCredentialsDict, Template, UploadTemplateResponseDict class TemplatesClient: @@ -17,6 +17,7 @@ class TemplatesClient: 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: """Upload a new template from a container image URI. @@ -32,16 +33,17 @@ def create(self, *, name: str, uri: str, credentials: RegistryCredentialsDict | data: UploadTemplateResponseDict = self._transport.request_json("POST", "/v1/template", json=payload, expected_status=201) # type: ignore[assignment] return Template.from_dict(data) - def rename(self, template_id: str, *, name: str) -> Template: + @intercept_errors("Failed to rename template: ") + def rename(self, template_id: str, *, name: str) -> None: """Rename an existing template. Args: template_id: ID of the template to rename. name: New template name. """ - data: UploadTemplateResponseDict = self._transport.request_json("PATCH", f"/v1/template/{template_id}", json={"name": name}) # type: ignore[assignment] - return Template.from_dict(data) + self._transport.request("PATCH", f"/v1/template/{template_id}", json={"name": name}, expected_status=204) + @intercept_errors("Failed to delete template: ") def delete(self, template_id: str) -> None: """Delete a template by ID.""" self._transport.request("DELETE", f"/v1/template/{template_id}", expected_status=204) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_utils/__init__.py b/tests/_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_utils/test_encoding.py b/tests/_utils/test_encoding.py new file mode 100644 index 0000000..ef30e0f --- /dev/null +++ b/tests/_utils/test_encoding.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from leap0._utils.encoding import b64decode_bytes, b64decode_text, b64encode_bytes, b64encode_text + + +class TestBase64: + def test_bytes_roundtrip(self): + data = b"hello world" + assert b64decode_bytes(b64encode_bytes(data)) == data + + def test_text_roundtrip(self): + assert b64decode_text(b64encode_text("hello world")) == "hello world" + + def test_text_utf8(self): + text = "unicode: \u00e9\u00e8\u00ea" + assert b64decode_text(b64encode_text(text, "utf-8"), "utf-8") == text diff --git a/tests/_utils/test_stream.py b/tests/_utils/test_stream.py new file mode 100644 index 0000000..89ab426 --- /dev/null +++ b/tests/_utils/test_stream.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from leap0._utils.stream import iter_ndjson, iter_sse_events + + +class TestIterSseEvents: + def test_standard_events(self): + assert list(iter_sse_events(["data: {\"a\": 1}", "", "data: {\"b\": 2}", ""])) == [{"a": 1}, {"b": 2}] + + def test_carriage_return(self): + assert list(iter_sse_events(["data: {\"x\": 1}\r", "\r"])) == [{"x": 1}] + + def test_comments_skipped(self): + assert list(iter_sse_events([": comment", "data: {\"a\": 1}", ""])) == [{"a": 1}] + + def test_flush_at_end(self): + assert list(iter_sse_events(["data: {\"z\": 99}"])) == [{"z": 99}] + + def test_empty(self): + assert list(iter_sse_events([])) == [] + + def test_only_comments(self): + assert list(iter_sse_events([": c1", ": c2"])) == [] + + def test_only_blanks(self): + assert list(iter_sse_events(["", "", ""])) == [] + + def test_multiline_data(self): + assert list(iter_sse_events(['data: {"a":', 'data: 1}', ''])) == [{"a": 1}] + + def test_leading_space_stripped(self): + assert list(iter_sse_events(["data: {\"s\": 1}", ""])) == [{"s": 1}] + + def test_non_data_fields_ignored(self): + assert list(iter_sse_events(["event: update", "id: 42", "data: {\"ok\": true}", ""])) == [{"ok": True}] + + +class TestIterNdjson: + def test_standard(self): + assert list(iter_ndjson(['{"a": 1}', '{"b": 2}'])) == [{"a": 1}, {"b": 2}] + + def test_blank_lines_skipped(self): + assert list(iter_ndjson(['{"a": 1}', '', '{"b": 2}'])) == [{"a": 1}, {"b": 2}] + + def test_empty(self): + assert list(iter_ndjson([])) == [] diff --git a/tests/_utils/test_url.py b/tests/_utils/test_url.py new file mode 100644 index 0000000..dfb3fff --- /dev/null +++ b/tests/_utils/test_url.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import pytest + +from leap0._utils.url import ensure_leading_slash, file_uri, sandbox_base_url, websocket_url_from_http + + +class TestSandboxBaseUrl: + def test_basic(self): + assert sandbox_base_url("sbx-123", "sandbox.leap0.dev") == "https://sbx-123.sandbox.leap0.dev" + + def test_with_port(self): + assert sandbox_base_url("sbx-123", "sandbox.leap0.dev", port=8080) == "https://sbx-123-8080.sandbox.leap0.dev" + + def test_strips_trailing_slash(self): + assert sandbox_base_url("sbx-123", "sandbox.leap0.dev/") == "https://sbx-123.sandbox.leap0.dev" + + def test_raises_on_missing_domain(self): + with pytest.raises(ValueError): + sandbox_base_url("sbx-123", None) + + def test_raises_on_empty_domain(self): + with pytest.raises(ValueError): + sandbox_base_url("sbx-123", "") + + +class TestWebsocketUrlFromHttp: + def test_https_to_wss(self): + assert websocket_url_from_http("https://example.com/ws") == "wss://example.com/ws" + + def test_http_to_ws(self): + assert websocket_url_from_http("http://localhost:8080/ws") == "ws://localhost:8080/ws" + + def test_other_scheme(self): + assert websocket_url_from_http("wss://already.ws") == "wss://already.ws" + + +class TestEnsureLeadingSlash: + def test_already_has(self): + assert ensure_leading_slash("/path") == "/path" + + def test_missing(self): + assert ensure_leading_slash("path") == "/path" + + def test_empty(self): + assert ensure_leading_slash("") == "/" + + +class TestFileUri: + def test_absolute(self): + assert file_uri("/home/user/file.py") == "file:///home/user/file.py" + + def test_relative(self): + assert file_uri("home/user/file.py") == "file:///home/user/file.py" diff --git a/tests/common/__init__.py b/tests/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/common/test_code_interpreter.py b/tests/common/test_code_interpreter.py new file mode 100644 index 0000000..cd60d5e --- /dev/null +++ b/tests/common/test_code_interpreter.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import base64 + +from leap0.common.code_interpreter import ( + CodeContext, CodeExecutionError, CodeExecutionOutput, CodeExecutionResult, ExecutionLogs, StreamEvent, +) + + +class TestCodeExecutionOutput: + def test_full(self): + o = CodeExecutionOutput.from_dict({"is_primary": True, "text": "hello", + "png": base64.b64encode(b"PNG").decode(), + "json": {"key": "val"}, "extra": {"custom": True}}) + assert o.is_primary is True + assert o.is_main_result is True + assert o.json_data == {"key": "val"} + + def test_png_bytes(self): + o = CodeExecutionOutput(png=base64.b64encode(b"PNG_DATA").decode()) + assert o.png_bytes() == b"PNG_DATA" + + def test_png_bytes_none(self): + assert CodeExecutionOutput().png_bytes() is None + + def test_jpeg_bytes(self): + o = CodeExecutionOutput(jpeg=base64.b64encode(b"JPEG").decode()) + assert o.jpeg_bytes() == b"JPEG" + + def test_pdf_bytes(self): + o = CodeExecutionOutput(pdf=base64.b64encode(b"PDF").decode()) + assert o.pdf_bytes() == b"PDF" + + def test_empty_dict(self): + o = CodeExecutionOutput.from_dict({}) + assert o.is_primary is False + assert o.text is None + + +class TestCodeExecutionError: + def test_from_dict(self): + e = CodeExecutionError.from_dict({"name": "ValueError", "value": "bad", "traceback": "line 1"}) + assert e.name == "ValueError" + + def test_empty_dict(self): + assert CodeExecutionError.from_dict({}).name == "" + + +class TestExecutionLogs: + def test_from_dict(self): + logs = ExecutionLogs.from_dict({"stdout": ["hello"], "stderr": ["oops"]}) + assert logs.stdout == ["hello"] + + def test_null_lists(self): + assert ExecutionLogs.from_dict({"stdout": None}).stdout == [] + + def test_empty_dict(self): + assert ExecutionLogs.from_dict({}).stdout == [] + + +class TestCodeExecutionResult: + def test_main_text_primary(self): + r = CodeExecutionResult.from_dict({"items": [{"text": "secondary"}, {"text": "primary", "is_primary": True}], + "logs": {}, "error": None, "execution_count": 1}) + assert r.main_text == "primary" + + def test_main_text_fallback(self): + r = CodeExecutionResult.from_dict({"items": [{"text": "first"}, {"text": "last"}], + "logs": {}, "error": None, "execution_count": 1}) + assert r.main_text == "last" + + def test_main_text_empty(self): + assert CodeExecutionResult.from_dict({"items": [], "logs": {}, "error": None, "execution_count": 0}).main_text is None + + def test_with_error(self): + r = CodeExecutionResult.from_dict({"items": [], "logs": {"stdout": ["out"]}, + "error": {"name": "Err", "value": "msg", "traceback": "tb"}, "execution_count": 1}) + assert r.error.name == "Err" + assert r.logs.stdout == ["out"] + + def test_context_id_from_body(self): + r = CodeExecutionResult.from_dict({"context_id": "ctx_1", "items": [], "logs": {}, "error": None, "execution_count": 0}) + assert r.context_id == "ctx_1" + + def test_context_id_missing(self): + assert CodeExecutionResult.from_dict({"items": [], "logs": {}, "error": None, "execution_count": 0}).context_id is None + + +class TestStreamEvent: + def test_integer_types(self): + assert StreamEvent.from_dict({"type": 0, "data": "hello"}).type == "stdout" + assert StreamEvent.from_dict({"type": 1, "data": "err"}).type == "stderr" + assert StreamEvent.from_dict({"type": 2, "data": "", "code": 0}).type == "exit" + assert StreamEvent.from_dict({"type": 3, "data": "bad"}).type == "error" + + def test_string_type(self): + assert StreamEvent.from_dict({"type": "stdout", "data": "hi"}).type == "stdout" + + def test_unknown_integer(self): + assert StreamEvent.from_dict({"type": 99, "data": ""}).type == "99" + + def test_empty_dict(self): + e = StreamEvent.from_dict({}) + assert e.type == "" + assert e.code is None + + +class TestCodeContext: + def test_integer_languages(self): + assert CodeContext.from_dict({"id": "ctx_1", "language": 1}).language == "python" + assert CodeContext.from_dict({"id": "ctx_2", "language": 2}).language == "typescript" + + def test_string_language(self): + assert CodeContext.from_dict({"id": "ctx_3", "language": "python"}).language == "python" + + def test_empty_dict(self): + c = CodeContext.from_dict({}) + assert c.id == "" + assert c.language == "" diff --git a/tests/common/test_config.py b/tests/common/test_config.py new file mode 100644 index 0000000..9c0b713 --- /dev/null +++ b/tests/common/test_config.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import os +from unittest.mock import patch + +import pytest + +from leap0.common.config import Leap0Config +from leap0.client import Leap0Client + + +class TestLeap0Config: + def test_explicit_api_key(self): + assert Leap0Config(api_key="my-key").api_key == "my-key" + + def test_api_key_from_env(self): + with patch.dict(os.environ, {"LEAP0_API_KEY": "env-key"}): + assert Leap0Config().api_key == "env-key" + + def test_raises_when_no_key(self): + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("LEAP0_API_KEY", None) + with pytest.raises(ValueError, match="api_key is required"): + Leap0Config() + + def test_explicit_key_overrides_env(self): + with patch.dict(os.environ, {"LEAP0_API_KEY": "env-key"}): + assert Leap0Config(api_key="explicit-key").api_key == "explicit-key" + + def test_default_values(self): + cfg = Leap0Config(api_key="key") + assert cfg.base_url == "https://api.leap0.dev" + assert cfg.sandbox_domain == "sandbox.leap0.dev" + assert cfg.timeout == 300.0 + + def test_base_url_from_env(self): + with patch.dict(os.environ, {"LEAP0_API_KEY": "key", "LEAP0_BASE_URL": "https://api.custom.dev"}): + assert Leap0Config().base_url == "https://api.custom.dev" + + def test_sandbox_domain_from_env(self): + with patch.dict(os.environ, {"LEAP0_API_KEY": "key", "LEAP0_SANDBOX_DOMAIN": "sandbox.custom.dev"}): + assert Leap0Config().sandbox_domain == "sandbox.custom.dev" + + +class TestLeap0Client: + def test_raises_when_no_key(self): + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("LEAP0_API_KEY", None) + with pytest.raises(ValueError): + Leap0Client() + + def test_creates_with_key(self): + client = Leap0Client(api_key="test-key") + assert client.sandboxes is not None + assert client.filesystem is not None + client.close() + + def test_context_manager(self): + with Leap0Client(api_key="test-key") as client: + assert client.sandboxes is not None diff --git a/tests/common/test_desktop.py b/tests/common/test_desktop.py new file mode 100644 index 0000000..f666684 --- /dev/null +++ b/tests/common/test_desktop.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from leap0.common.desktop import ( + DesktopDisplayInfo, DesktopHealth, DesktopPointerPosition, DesktopProcessErrors, + DesktopProcessLogs, DesktopProcessRestart, DesktopProcessStatus, DesktopProcessStatusList, + DesktopRecordingStatus, DesktopRecordingSummary, DesktopWindow, +) + + +class TestDesktopHealth: + def test_ok_true(self): + assert DesktopHealth.from_dict({"ok": True}).ok is True + + def test_empty_dict(self): + assert DesktopHealth.from_dict({}).ok is False + + +class TestDesktopDisplayInfo: + def test_from_dict(self): + d = DesktopDisplayInfo.from_dict({"display": ":0", "width": 1920, "height": 1080}) + assert d.width == 1920 + + +class TestDesktopWindow: + def test_class_key(self): + assert DesktopWindow.from_dict({"id": "w1", "class": "Firefox"}).window_class == "Firefox" + + def test_class_underscore_key(self): + assert DesktopWindow.from_dict({"id": "w2", "class_": "Chrome"}).window_class == "Chrome" + + def test_prefers_class(self): + assert DesktopWindow.from_dict({"id": "w3", "class": "A", "class_": "B"}).window_class == "A" + + def test_empty_dict(self): + assert DesktopWindow.from_dict({}).window_class == "" + + +class TestDesktopPointerPosition: + def test_from_dict(self): + p = DesktopPointerPosition.from_dict({"x": 100, "y": 200}) + assert p.x == 100 + + +class TestDesktopRecordingStatus: + def test_from_dict(self): + r = DesktopRecordingStatus.from_dict({"id": "rec_1", "active": True, "started_at": "2025-01-01", + "display": ":0", "resolution": "1920x1080"}) + assert r.active is True + + +class TestDesktopRecordingSummary: + def test_from_dict(self): + assert DesktopRecordingSummary.from_dict({"id": "rec_1", "file_name": "a.mp4", "size_bytes": 1024}).size_bytes == 1024 + + +class TestDesktopProcessStatus: + def test_from_dict(self): + p = DesktopProcessStatus.from_dict({"name": "xvfb", "running": True, "pid": 123}) + assert p.running is True + + +class TestDesktopProcessStatusList: + def test_from_dict(self): + sl = DesktopProcessStatusList.from_dict({"status": "running", "items": [{"name": "xvfb", "running": True, "pid": 1}], + "running": 1, "total": 4}) + assert len(sl.items) == 1 + assert sl.total == 4 + + def test_empty_dict(self): + assert DesktopProcessStatusList.from_dict({}).items == [] + + +class TestDesktopProcessRestart: + def test_with_status(self): + r = DesktopProcessRestart.from_dict({"message": "restarted", "status": {"name": "xvfb", "running": True, "pid": 42}}) + assert r.status.pid == 42 + + def test_without_status(self): + assert DesktopProcessRestart.from_dict({"message": "ok"}).status is None + + +class TestDesktopProcessLogs: + def test_from_dict(self): + assert DesktopProcessLogs.from_dict({"process": "xvfb", "logs": "output..."}).logs == "output..." + + +class TestDesktopProcessErrors: + def test_from_dict(self): + assert DesktopProcessErrors.from_dict({"process": "x11vnc", "errors": "fail"}).errors == "fail" diff --git a/tests/common/test_errors.py b/tests/common/test_errors.py new file mode 100644 index 0000000..de41de1 --- /dev/null +++ b/tests/common/test_errors.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import httpx +import pytest + +from leap0.common.errors import Leap0Error, Leap0NotFoundError, Leap0TimeoutError +from leap0._utils.errors import intercept_errors + + +class TestInterceptErrors: + def test_prefix_prepended(self): + @intercept_errors("Failed to delete file: ") + def failing(): + raise Leap0NotFoundError("Request failed", 404, body='{"message":"not found"}') + + with pytest.raises(Leap0NotFoundError) as exc_info: + failing() + assert exc_info.value.message.startswith("Failed to delete file: ") + + def test_httpx_timeout(self): + @intercept_errors("Failed to read file: ") + def failing(): + raise httpx.ReadTimeout("timed out") + + with pytest.raises(Leap0TimeoutError): + failing() + + def test_httpx_connect_error(self): + @intercept_errors("Failed to create sandbox: ") + def failing(): + raise httpx.ConnectError("connection refused") + + with pytest.raises(Leap0Error) as exc_info: + failing() + assert type(exc_info.value) is Leap0Error + + def test_generic_exception(self): + @intercept_errors("Failed: ") + def failing(): + raise RuntimeError("broke") + + with pytest.raises(Leap0Error): + failing() + + def test_no_double_prefix(self): + @intercept_errors("Failed to write file: ") + def failing(): + raise Leap0Error("Failed to write file: already prefixed", 400) + + with pytest.raises(Leap0Error) as exc_info: + failing() + assert not exc_info.value.message.startswith("Failed to write file: Failed to write file: ") + + def test_success_passes_through(self): + @intercept_errors("Nope: ") + def ok(): + return 42 + + assert ok() == 42 diff --git a/tests/common/test_filesystem.py b/tests/common/test_filesystem.py new file mode 100644 index 0000000..944a92e --- /dev/null +++ b/tests/common/test_filesystem.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from leap0.common.filesystem import ( + EditFileResult, EditResult, FileEdit, FileInfo, LsResult, SearchMatch, TreeEntry, TreeResult, +) + + +class TestFileInfo: + def test_full_dict(self): + f = FileInfo.from_dict({"name": "main.py", "path": "/workspace/main.py", "is_dir": False, + "size": 1234, "mode": "644", "mtime": 1700000000, "owner": "root", + "group": "root", "is_symlink": True, "link_target": "/usr/bin/python"}) + assert f.name == "main.py" + assert f.size == 1234 + assert f.is_symlink is True + + def test_empty_dict(self): + f = FileInfo.from_dict({}) + assert f.name == "" + assert f.size == 0 + + +class TestLsResult: + def test_from_dict(self): + r = LsResult.from_dict({"items": [{"name": "a.py", "path": "/a.py"}, {"name": "b.py", "path": "/b.py"}]}) + assert len(r.items) == 2 + + def test_empty_items(self): + assert LsResult.from_dict({"items": []}).items == [] + + def test_missing_items(self): + assert LsResult.from_dict({}).items == [] + + +class TestFileEdit: + def test_to_dict(self): + assert FileEdit(find="hello", replace="world").to_dict() == {"find": "hello", "replace": "world"} + + def test_to_dict_empty_replace(self): + assert FileEdit(find="delete_me").to_dict() == {"find": "delete_me", "replace": ""} + + +class TestEditFileResult: + def test_from_dict(self): + r = EditFileResult.from_dict({"diff": "--- a\n+++ b", "replacements": 3}) + assert r.replacements == 3 + + def test_empty_dict(self): + assert EditFileResult.from_dict({}).diff == "" + + +class TestEditResult: + def test_from_dict(self): + r = EditResult.from_dict({"file": "a.py", "success": True, "error": ""}) + assert r.success is True + + +class TestSearchMatch: + def test_from_dict(self): + m = SearchMatch.from_dict({"path": "/a.py", "line": 42, "content": "TODO"}) + assert m.line == 42 + + def test_empty_dict(self): + assert SearchMatch.from_dict({}).line == 0 + + +class TestTreeEntry: + def test_with_children(self): + t = TreeEntry.from_dict({"name": "src", "type": "directory", + "children": [{"name": "main.py", "type": "file"}]}) + assert len(t.children) == 1 + assert t.children[0].name == "main.py" + + def test_empty_dict(self): + t = TreeEntry.from_dict({}) + assert t.name == "" + assert t.children == [] + + +class TestTreeResult: + def test_from_dict(self): + assert len(TreeResult.from_dict({"items": [{"name": "a", "type": "file"}]}).items) == 1 + + def test_missing_items(self): + assert TreeResult.from_dict({}).items == [] diff --git a/tests/common/test_git.py b/tests/common/test_git.py new file mode 100644 index 0000000..55cfcd9 --- /dev/null +++ b/tests/common/test_git.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from leap0.common.git import GitCommitResult, GitResult + + +class TestGitResult: + def test_from_dict(self): + r = GitResult.from_dict({"output": "ok", "exit_code": 0}) + assert r.output == "ok" + + def test_empty_dict(self): + assert GitResult.from_dict({}).exit_code == 0 + + +class TestGitCommitResult: + def test_with_result(self): + r = GitCommitResult.from_dict({"sha": "abc123", "result": {"output": "committed", "exit_code": 0}}) + assert r.sha == "abc123" + assert r.result.output == "committed" + + def test_without_result(self): + assert GitCommitResult.from_dict({"sha": "abc"}).result is None + + def test_null_result(self): + r = GitCommitResult.from_dict({"sha": None, "result": None}) + assert r.sha is None + assert r.result is None diff --git a/tests/common/test_lsp.py b/tests/common/test_lsp.py new file mode 100644 index 0000000..b5f2a3e --- /dev/null +++ b/tests/common/test_lsp.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from leap0.common.lsp import LspResponse + + +class TestLspResponse: + def test_from_dict(self): + assert LspResponse.from_dict({"success": True}).success is True + + def test_empty_dict(self): + assert LspResponse.from_dict({}).success is False diff --git a/tests/common/test_process.py b/tests/common/test_process.py new file mode 100644 index 0000000..d0fe7f9 --- /dev/null +++ b/tests/common/test_process.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from leap0.common.process import ProcessResult + + +class TestProcessResult: + def test_from_dict(self): + r = ProcessResult.from_dict({"exit_code": 1, "result": "error output"}) + assert r.exit_code == 1 + assert r.result == "error output" diff --git a/tests/common/test_pty.py b/tests/common/test_pty.py new file mode 100644 index 0000000..423ed68 --- /dev/null +++ b/tests/common/test_pty.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from leap0.common.pty import PtySession + + +class TestPtySession: + def test_from_dict_with_id(self): + p = PtySession.from_dict({"id": "pty_1", "cwd": "/home/user", "cols": 80, "rows": 24, "active": True}) + assert p.id == "pty_1" + assert p.cols == 80 + + def test_from_dict_with_session_id(self): + assert PtySession.from_dict({"session_id": "pty_2", "cols": 120}).id == "pty_2" + + def test_prefers_id_over_session_id(self): + assert PtySession.from_dict({"id": "pty_a", "session_id": "pty_b"}).id == "pty_a" + + def test_empty_dict(self): + p = PtySession.from_dict({}) + assert p.id == "" + assert p.envs == {} diff --git a/tests/common/test_sandbox.py b/tests/common/test_sandbox.py new file mode 100644 index 0000000..54a6252 --- /dev/null +++ b/tests/common/test_sandbox.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from leap0.common.sandbox import Sandbox, SandboxStatus, sandbox_id_of + + +class TestSandboxIdOf: + def test_from_string(self): + assert sandbox_id_of("sbx-123") == "sbx-123" + + def test_from_sandbox(self): + assert sandbox_id_of(Sandbox(id="sbx-abc")) == "sbx-abc" + + def test_from_sandbox_status(self): + s = SandboxStatus(id="sbx-xyz", template_id="t", vcpu=1, memory_mib=512, + disk_mib=10240, state="running", auto_pause=False, created_at="") + assert sandbox_id_of(s) == "sbx-xyz" + + +class TestSandbox: + def test_full_dict(self): + s = Sandbox.from_dict({"id": "sbx-1", "template_id": "tpl-1", "vcpu": 2, "memory_mib": 2048, + "disk_mib": 10240, "state": "running", "auto_pause": True, + "created_at": "2025-01-01", "network_policy": {"mode": "allow-all"}}) + assert s.id == "sbx-1" + assert s.vcpu == 2 + assert s.state == "running" + assert s.network_policy == {"mode": "allow-all"} + + def test_minimal_dict(self): + s = Sandbox.from_dict({"id": "sbx-2"}) + assert s.id == "sbx-2" + assert s.state == "starting" + assert s.network_policy is None + + +class TestSandboxStatus: + def test_full_dict(self): + s = SandboxStatus.from_dict({"id": "sbx-1", "template_id": "tpl-1", "vcpu": 4, "memory_mib": 4096, + "disk_mib": 10240, "state": "paused", "auto_pause": True, "created_at": "2025-01-01"}) + assert s.state == "paused" + assert s.vcpu == 4 + + def test_empty_dict(self): + s = SandboxStatus.from_dict({}) + assert s.id == "" + assert s.state == "starting" diff --git a/tests/common/test_snapshot.py b/tests/common/test_snapshot.py new file mode 100644 index 0000000..4e3bd40 --- /dev/null +++ b/tests/common/test_snapshot.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from leap0.common.snapshot import Snapshot, snapshot_id_of + + +class TestSnapshotIdOf: + def test_from_string(self): + assert snapshot_id_of("snap-123") == "snap-123" + + def test_from_snapshot(self): + assert snapshot_id_of(Snapshot(snapshot_id="snap-abc", name="my-snap")) == "snap-abc" + + +class TestSnapshot: + def test_id_property(self): + assert Snapshot(snapshot_id="snap-1", name="test").id == "snap-1" + + def test_from_dict_full(self): + s = Snapshot.from_dict({"snapshot_id": "snap-1", "name": "my-snap", "template_id": "tpl-1", + "vcpu": 2, "memory_mib": 1024, "disk_mib": 10240, + "network_policy": {"mode": "deny-all"}, "created_at": "2025-01-01"}) + assert s.snapshot_id == "snap-1" + assert s.network_policy == {"mode": "deny-all"} + + def test_from_dict_minimal(self): + s = Snapshot.from_dict({}) + assert s.snapshot_id == "" + assert s.name == "" diff --git a/tests/common/test_ssh.py b/tests/common/test_ssh.py new file mode 100644 index 0000000..3873cc9 --- /dev/null +++ b/tests/common/test_ssh.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from leap0.common.ssh import SshAccess, SshValidation + + +class TestSshAccess: + def test_from_dict(self): + s = SshAccess.from_dict({"id": "ssh-1", "password": "secret", "ssh_command": "ssh user@host", + "sandbox_id": "sbx-1", "expires_at": "2025-12-31", + "created_at": "2025-01-01", "updated_at": "2025-01-01"}) + assert s.id == "ssh-1" + assert s.password == "secret" + + +class TestSshValidation: + def test_from_dict(self): + v = SshValidation.from_dict({"valid": True, "sandbox_id": "sbx-1"}) + assert v.valid is True + + def test_empty_dict(self): + assert SshValidation.from_dict({}).valid is False diff --git a/tests/common/test_template.py b/tests/common/test_template.py new file mode 100644 index 0000000..83ade0e --- /dev/null +++ b/tests/common/test_template.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from leap0.common.template import ImageConfig, Template + + +class TestImageConfig: + def test_full(self): + c = ImageConfig.from_dict({"entrypoint": ["/bin/sh"], "cmd": ["-c", "echo hi"], + "working_dir": "/workspace", "user": "appuser", "env": {"PATH": "/usr/bin"}}) + assert c.entrypoint == ["/bin/sh"] + assert c.user == "appuser" + + def test_null_lists(self): + c = ImageConfig.from_dict({"entrypoint": None, "cmd": None}) + assert c.entrypoint == [] + assert c.cmd == [] + + +class TestTemplate: + def test_full(self): + t = Template.from_dict({"id": "tpl-1", "name": "my-template", "digest": "sha256:abc", + "image_config": {"entrypoint": ["/bin/sh"]}, "is_system": False, "created_at": "2025-01-01"}) + assert t.image_config.entrypoint == ["/bin/sh"] + + def test_null_image_config(self): + t = Template.from_dict({"id": "tpl-2", "name": "t2", "digest": "", "image_config": None, + "is_system": True, "created_at": ""}) + assert t.image_config is None + assert t.is_system is True diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..41c2462 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from leap0._transport import Transport + + +@pytest.fixture +def transport(): + return Transport(api_key="test-key", base_url="https://api.example.com") + + +@pytest.fixture +def mock_transport(): + t = MagicMock(spec=Transport) + t.auth_header = "authorization" + t.auth_value = "Bearer test-key" + return t diff --git a/tests/test_clients.py b/tests/test_clients.py deleted file mode 100644 index 812e319..0000000 --- a/tests/test_clients.py +++ /dev/null @@ -1,360 +0,0 @@ -"""Tests for service clients: URL construction and payload building via mock transport.""" -from __future__ import annotations - -from unittest.mock import MagicMock, call - -import httpx -import pytest - -from leap0._transport import Transport -from leap0.sandboxes import SandboxesClient -from leap0.snapshots import SnapshotsClient -from leap0.templates import TemplatesClient -from leap0.filesystem import FilesystemClient, _parse_multipart_response -from leap0.git import GitClient -from leap0.process import ProcessClient -from leap0.ssh import SshClient -from leap0.models import FileEdit, Sandbox, Snapshot - - -def _mock_transport() -> MagicMock: - t = MagicMock(spec=Transport) - t.auth_header = "authorization" - t.auth_value = "Bearer test-key" - return t - - -# SandboxesClient - -class TestSandboxesClient: - def test_create_url_and_payload(self): - t = _mock_transport() - t.request_json.return_value = { - "id": "sbx-1", "template_id": "tpl-1", "vcpu": 2, "memory_mib": 2048, - "disk_mib": 10240, "state": "starting", "auto_pause": False, "created_at": "", - } - client = SandboxesClient(t, sandbox_domain="sandbox.leap0.dev") - result = client.create(template_name="my-tpl", vcpu=2, memory_mib=2048) - - t.request_json.assert_called_once() - args, kwargs = t.request_json.call_args - assert args[0] == "POST" - assert args[1] == "/v1/sandbox" - assert kwargs["json"]["template_name"] == "my-tpl" - assert kwargs["json"]["vcpu"] == 2 - assert kwargs["expected_status"] == 201 - assert result.id == "sbx-1" - - def test_get_url(self): - t = _mock_transport() - t.request_json.return_value = { - "id": "sbx-1", "template_id": "t", "vcpu": 1, "memory_mib": 512, - "disk_mib": 10240, "state": "running", "auto_pause": False, "created_at": "", - } - client = SandboxesClient(t, sandbox_domain="sandbox.leap0.dev") - client.get("sbx-1") - - args, kwargs = t.request_json.call_args - assert args[0] == "GET" - assert args[1] == "/v1/sandbox/sbx-1/" - - def test_delete_url(self): - t = _mock_transport() - t.request.return_value = MagicMock(status_code=204) - client = SandboxesClient(t, sandbox_domain="sandbox.leap0.dev") - client.delete("sbx-1") - - args, kwargs = t.request.call_args - assert args[0] == "DELETE" - assert args[1] == "/v1/sandbox/sbx-1/" - assert kwargs["expected_status"] == 204 - - def test_pause_url(self): - t = _mock_transport() - t.request_json.return_value = { - "id": "sbx-1", "template_id": "t", "vcpu": 1, "memory_mib": 512, - "disk_mib": 10240, "state": "paused", "auto_pause": False, "created_at": "", - } - client = SandboxesClient(t, sandbox_domain="sandbox.leap0.dev") - client.pause("sbx-1") - - args, kwargs = t.request_json.call_args - assert args[0] == "POST" - assert args[1] == "/v1/sandbox/sbx-1/pause" - - def test_invoke_url(self): - client = SandboxesClient(_mock_transport(), sandbox_domain="sandbox.leap0.dev") - url = client.invoke_url("sbx-1", "/api/health") - assert url == "https://sbx-1.sandbox.leap0.dev/api/health" - - def test_invoke_url_with_port(self): - client = SandboxesClient(_mock_transport(), sandbox_domain="sandbox.leap0.dev") - url = client.invoke_url("sbx-1", "/api", port=3000) - assert url == "https://sbx-1-3000.sandbox.leap0.dev/api" - - def test_websocket_url(self): - client = SandboxesClient(_mock_transport(), sandbox_domain="sandbox.leap0.dev") - url = client.websocket_url("sbx-1", "/ws") - assert url == "wss://sbx-1.sandbox.leap0.dev/ws" - - def test_accepts_sandbox_object(self): - t = _mock_transport() - t.request_json.return_value = { - "id": "sbx-1", "template_id": "t", "vcpu": 1, "memory_mib": 512, - "disk_mib": 10240, "state": "running", "auto_pause": False, "created_at": "", - } - client = SandboxesClient(t, sandbox_domain="sandbox.leap0.dev") - sandbox = Sandbox(id="sbx-obj") - client.get(sandbox) - - args, _ = t.request_json.call_args - assert "sbx-obj" in args[1] - - -# SnapshotsClient - -class TestSnapshotsClient: - def test_create_url(self): - t = _mock_transport() - t.request_json.return_value = { - "snapshot_id": "snap-1", "name": "s", "template_id": "t", - "vcpu": 1, "memory_mib": 512, "disk_mib": 10240, - "network_policy": None, "created_at": "", - } - client = SnapshotsClient(t) - client.create("sbx-1", name="my-snap") - - args, kwargs = t.request_json.call_args - assert args[0] == "POST" - assert args[1] == "/v1/sandbox/sbx-1/snapshot/create" - assert kwargs["json"]["name"] == "my-snap" - - def test_resume_url(self): - t = _mock_transport() - t.request_json.return_value = { - "id": "sbx-new", "template_id": "t", "vcpu": 1, "memory_mib": 512, - "disk_mib": 10240, "state": "starting", "auto_pause": False, "created_at": "", - } - client = SnapshotsClient(t) - client.resume(snapshot_name="my-snap") - - args, kwargs = t.request_json.call_args - assert args[0] == "POST" - assert args[1] == "/v1/snapshot/resume" - assert kwargs["json"]["snapshot_name"] == "my-snap" - - def test_delete_url(self): - t = _mock_transport() - t.request.return_value = MagicMock(status_code=204) - client = SnapshotsClient(t) - client.delete("snap-1") - - args, kwargs = t.request.call_args - assert args[0] == "DELETE" - assert args[1] == "/v1/snapshot/snap-1" - - def test_delete_accepts_snapshot_object(self): - t = _mock_transport() - t.request.return_value = MagicMock(status_code=204) - client = SnapshotsClient(t) - snap = Snapshot(snapshot_id="snap-obj", name="n") - client.delete(snap) - - args, _ = t.request.call_args - assert "snap-obj" in args[1] - - -# TemplatesClient - -class TestTemplatesClient: - def test_create_url_and_payload(self): - t = _mock_transport() - t.request_json.return_value = { - "id": "tpl-1", "name": "my-tpl", "digest": "sha256:abc", - "image_config": None, "is_system": False, "created_at": "", - } - client = TemplatesClient(t) - result = client.create(name="my-tpl", uri="docker.io/library/python:3.12") - - args, kwargs = t.request_json.call_args - assert args[0] == "POST" - assert args[1] == "/v1/template" - assert kwargs["json"]["name"] == "my-tpl" - assert kwargs["json"]["uri"] == "docker.io/library/python:3.12" - assert result.name == "my-tpl" - - def test_rename_url(self): - t = _mock_transport() - t.request_json.return_value = { - "id": "tpl-1", "name": "new-name", "digest": "", - "image_config": None, "is_system": False, "created_at": "", - } - client = TemplatesClient(t) - result = client.rename("tpl-1", name="new-name") - - args, kwargs = t.request_json.call_args - assert args[0] == "PATCH" - assert args[1] == "/v1/template/tpl-1" - assert kwargs["json"] == {"name": "new-name"} - assert result.name == "new-name" - - def test_delete_url(self): - t = _mock_transport() - t.request.return_value = MagicMock(status_code=204) - client = TemplatesClient(t) - client.delete("tpl-1") - - args, kwargs = t.request.call_args - assert args[0] == "DELETE" - assert args[1] == "/v1/template/tpl-1" - assert kwargs["expected_status"] == 204 - - -# FilesystemClient - -class TestFilesystemClient: - def test_ls_url(self): - t = _mock_transport() - t.request_json.return_value = {"items": []} - client = FilesystemClient(t) - client.ls("sbx-1", path="/workspace") - - args, kwargs = t.request_json.call_args - assert args[0] == "POST" - assert args[1] == "/v1/sandbox/sbx-1/filesystem/ls" - assert kwargs["json"]["path"] == "/workspace" - - def test_mkdir_url(self): - t = _mock_transport() - t.request.return_value = MagicMock(status_code=204) - client = FilesystemClient(t) - client.mkdir("sbx-1", path="/workspace/src", recursive=True) - - args, kwargs = t.request.call_args - assert args[0] == "POST" - assert "/filesystem/mkdir" in args[1] - assert kwargs["json"]["recursive"] is True - - def test_exists_url(self): - t = _mock_transport() - t.request_json.return_value = {"exists": True} - client = FilesystemClient(t) - result = client.exists("sbx-1", path="/workspace/main.py") - - assert result is True - args, kwargs = t.request_json.call_args - assert "/filesystem/exists" in args[1] - - def test_glob_url(self): - t = _mock_transport() - t.request_json.return_value = {"items": ["/a.ts", "/b.ts"]} - client = FilesystemClient(t) - result = client.glob("sbx-1", path="/workspace", pattern="*.ts") - - assert result == ["/a.ts", "/b.ts"] - - def test_edit_file_url(self): - t = _mock_transport() - t.request_json.return_value = {"diff": "...", "replacements": 1} - client = FilesystemClient(t) - edits = [FileEdit(find="old", replace="new")] - client.edit_file("sbx-1", path="/a.py", edits=edits) - - args, kwargs = t.request_json.call_args - assert "/filesystem/edit-file" in args[1] - assert kwargs["json"]["edits"] == [{"find": "old", "replace": "new"}] - - -# Multipart parser - -class TestParseMultipartResponse: - def test_valid_multipart(self): - boundary = "boundary123" - body = ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="/workspace/a.txt"\r\n' - f"Content-Type: application/octet-stream\r\n\r\n" - f"content a\r\n" - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="/workspace/b.txt"\r\n' - f"Content-Type: application/octet-stream\r\n\r\n" - f"content b\r\n" - f"--{boundary}--\r\n" - ).encode() - ct = f"multipart/form-data; boundary={boundary}" - result = _parse_multipart_response(ct, body) - assert result["/workspace/a.txt"] == b"content a" - assert result["/workspace/b.txt"] == b"content b" - - def test_non_multipart_raises(self): - with pytest.raises(ValueError, match="Expected multipart"): - _parse_multipart_response("application/json", b'{"error": "bad"}') - - -# GitClient - -class TestGitClient: - def test_clone_url(self): - t = _mock_transport() - t.request_json.return_value = {"output": "cloned", "exit_code": 0} - client = GitClient(t) - client.clone("sbx-1", url="https://github.com/test/repo.git", path="/workspace/repo") - - args, kwargs = t.request_json.call_args - assert args[0] == "POST" - assert args[1] == "/v1/sandbox/sbx-1/git/clone" - assert kwargs["json"]["url"] == "https://github.com/test/repo.git" - - def test_status_url(self): - t = _mock_transport() - t.request_json.return_value = {"output": "", "exit_code": 0} - client = GitClient(t) - client.status("sbx-1", path="/workspace/repo") - - args, kwargs = t.request_json.call_args - assert "/git/status" in args[1] - - -# ProcessClient - -class TestProcessClient: - def test_execute_url(self): - t = _mock_transport() - t.request_json.return_value = {"exit_code": 0, "result": "hello"} - client = ProcessClient(t) - result = client.execute("sbx-1", command="echo hello") - - args, kwargs = t.request_json.call_args - assert args[0] == "POST" - assert "/process/execute" in args[1] - assert kwargs["json"]["command"] == "echo hello" - assert result.exit_code == 0 - assert result.result == "hello" - - -# SshClient - -class TestSshClient: - def test_create_access_url(self): - t = _mock_transport() - t.request_json.return_value = { - "id": "ssh-1", "password": "pw", "ssh_command": "ssh u@h", - "sandbox_id": "sbx-1", - } - client = SshClient(t) - result = client.create_access("sbx-1") - - args, kwargs = t.request_json.call_args - assert args[0] == "POST" - assert "/ssh/access" in args[1] - assert result.id == "ssh-1" - - def test_delete_access_url(self): - t = _mock_transport() - t.request.return_value = MagicMock(status_code=204) - client = SshClient(t) - client.delete_access("sbx-1") - - args, kwargs = t.request.call_args - assert args[0] == "DELETE" - assert "/ssh/access" in args[1] diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index 0f6eecb..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Tests for Leap0Config and Leap0Client initialization.""" -from __future__ import annotations - -import os -from unittest.mock import patch - -import pytest - -from leap0.config import Leap0Config -from leap0.client import Leap0Client - - -class TestLeap0Config: - def test_explicit_api_key(self): - cfg = Leap0Config(api_key="my-key") - assert cfg.api_key == "my-key" - - def test_api_key_from_env(self): - with patch.dict(os.environ, {"LEAP0_API_KEY": "env-key"}): - cfg = Leap0Config() - assert cfg.api_key == "env-key" - - def test_raises_when_no_key(self): - with patch.dict(os.environ, {}, clear=True): - os.environ.pop("LEAP0_API_KEY", None) - with pytest.raises(ValueError, match="api_key is required"): - Leap0Config() - - def test_explicit_key_overrides_env(self): - with patch.dict(os.environ, {"LEAP0_API_KEY": "env-key"}): - cfg = Leap0Config(api_key="explicit-key") - assert cfg.api_key == "explicit-key" - - def test_default_values(self): - cfg = Leap0Config(api_key="key") - assert cfg.base_url == "https://api.leap0.dev" - assert cfg.sandbox_domain == "sandbox.leap0.dev" - assert cfg.timeout == 300.0 - assert cfg.auth_header == "authorization" - assert cfg.bearer is True - - def test_base_url_from_env(self): - with patch.dict(os.environ, {"LEAP0_API_KEY": "key", "LEAP0_BASE_URL": "https://api.custom.dev"}): - cfg = Leap0Config() - assert cfg.base_url == "https://api.custom.dev" - - def test_sandbox_domain_from_env(self): - with patch.dict(os.environ, {"LEAP0_API_KEY": "key", "LEAP0_SANDBOX_DOMAIN": "sandbox.custom.dev"}): - cfg = Leap0Config() - assert cfg.sandbox_domain == "sandbox.custom.dev" - - def test_explicit_base_url_overrides_env(self): - with patch.dict(os.environ, {"LEAP0_BASE_URL": "https://api.env.dev"}): - cfg = Leap0Config(api_key="key", base_url="https://api.explicit.dev") - assert cfg.base_url == "https://api.explicit.dev" - - def test_explicit_sandbox_domain_overrides_env(self): - with patch.dict(os.environ, {"LEAP0_SANDBOX_DOMAIN": "sandbox.env.dev"}): - cfg = Leap0Config(api_key="key", sandbox_domain="sandbox.explicit.dev") - assert cfg.sandbox_domain == "sandbox.explicit.dev" - - -class TestLeap0Client: - def test_raises_when_no_key(self): - with patch.dict(os.environ, {}, clear=True): - os.environ.pop("LEAP0_API_KEY", None) - with pytest.raises(ValueError, match="api_key is required"): - Leap0Client() - - def test_creates_with_explicit_key(self): - client = Leap0Client(api_key="test-key") - assert client.sandboxes is not None - assert client.templates is not None - assert client.filesystem is not None - assert client.git is not None - assert client.process is not None - assert client.pty is not None - assert client.lsp is not None - assert client.ssh is not None - assert client.code_interpreter is not None - assert client.desktop is not None - assert client.snapshots is not None - client.close() - - def test_context_manager(self): - with Leap0Client(api_key="test-key") as client: - assert client.sandboxes is not None - - def test_api_key_from_env(self): - with patch.dict(os.environ, {"LEAP0_API_KEY": "env-key"}): - client = Leap0Client() - client.close() - - def test_base_url_from_env(self): - with patch.dict(os.environ, {"LEAP0_BASE_URL": "https://api.custom.dev"}): - client = Leap0Client(api_key="test-key") - assert client._transport.base_url == "https://api.custom.dev" - client.close() - - def test_sandbox_domain_from_env(self): - with patch.dict(os.environ, {"LEAP0_SANDBOX_DOMAIN": "sandbox.custom.dev"}): - client = Leap0Client(api_key="test-key") - assert client.sandboxes._sandbox_domain == "sandbox.custom.dev" - assert client.desktop._sandbox_domain == "sandbox.custom.dev" - assert client.code_interpreter._sandbox_domain == "sandbox.custom.dev" - client.close() - - def test_explicit_base_url_overrides_env(self): - with patch.dict(os.environ, {"LEAP0_BASE_URL": "https://api.env.dev"}): - client = Leap0Client(api_key="test-key", base_url="https://api.explicit.dev") - assert client._transport.base_url == "https://api.explicit.dev" - client.close() - - def test_explicit_sandbox_domain_overrides_env(self): - with patch.dict(os.environ, {"LEAP0_SANDBOX_DOMAIN": "sandbox.env.dev"}): - client = Leap0Client(api_key="test-key", sandbox_domain="sandbox.explicit.dev") - assert client.sandboxes._sandbox_domain == "sandbox.explicit.dev" - client.close() diff --git a/tests/test_filesystem.py b/tests/test_filesystem.py new file mode 100644 index 0000000..4e725d9 --- /dev/null +++ b/tests/test_filesystem.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import pytest + +from leap0.filesystem import FilesystemClient, _parse_multipart_response +from leap0.common.filesystem import FileEdit + + +class TestFilesystemClient: + def test_ls(self, mock_transport): + mock_transport.request_json.return_value = {"items": []} + FilesystemClient(mock_transport).ls("sbx-1", path="/workspace") + assert "/filesystem/ls" in mock_transport.request_json.call_args[0][1] + + def test_mkdir(self, mock_transport): + from unittest.mock import MagicMock + mock_transport.request.return_value = MagicMock(status_code=204) + FilesystemClient(mock_transport).mkdir("sbx-1", path="/workspace/src", recursive=True) + assert mock_transport.request.call_args[1]["json"]["recursive"] is True + + def test_exists(self, mock_transport): + mock_transport.request_json.return_value = {"exists": True} + assert FilesystemClient(mock_transport).exists("sbx-1", path="/workspace/main.py") is True + + def test_glob(self, mock_transport): + mock_transport.request_json.return_value = {"items": ["/a.ts", "/b.ts"]} + assert FilesystemClient(mock_transport).glob("sbx-1", path="/workspace", pattern="*.ts") == ["/a.ts", "/b.ts"] + + def test_edit_file(self, mock_transport): + mock_transport.request_json.return_value = {"diff": "...", "replacements": 1} + FilesystemClient(mock_transport).edit_file("sbx-1", path="/a.py", edits=[FileEdit(find="old", replace="new")]) + assert mock_transport.request_json.call_args[1]["json"]["edits"] == [{"find": "old", "replace": "new"}] + + +class TestParseMultipartResponse: + def test_valid(self): + boundary = "boundary123" + body = ( + f"--{boundary}\r\nContent-Disposition: form-data; name=\"/a.txt\"\r\n" + f"Content-Type: application/octet-stream\r\n\r\ncontent a\r\n" + f"--{boundary}--\r\n" + ).encode() + result = _parse_multipart_response(f"multipart/form-data; boundary={boundary}", body) + assert result["/a.txt"] == b"content a" + + def test_non_multipart_raises(self): + with pytest.raises(ValueError, match="Expected multipart"): + _parse_multipart_response("application/json", b'{"error": "bad"}') diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..6333758 --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from leap0.git import GitClient + + +class TestGitClient: + def test_clone(self, mock_transport): + mock_transport.request_json.return_value = {"output": "cloned", "exit_code": 0} + GitClient(mock_transport).clone("sbx-1", url="https://github.com/test/repo.git", path="/workspace/repo") + args, kwargs = mock_transport.request_json.call_args + assert args[1] == "/v1/sandbox/sbx-1/git/clone" + assert kwargs["json"]["url"] == "https://github.com/test/repo.git" + + def test_status(self, mock_transport): + mock_transport.request_json.return_value = {"output": "", "exit_code": 0} + GitClient(mock_transport).status("sbx-1", path="/workspace/repo") + assert "/git/status" in mock_transport.request_json.call_args[0][1] diff --git a/tests/test_models.py b/tests/test_models.py deleted file mode 100644 index 945658f..0000000 --- a/tests/test_models.py +++ /dev/null @@ -1,824 +0,0 @@ -"""Tests for model from_dict parsing, edge cases, and helper methods.""" -from __future__ import annotations - -import base64 - -import pytest - -from leap0.models import ( - CodeContext, - CodeExecutionError, - CodeExecutionOutput, - CodeExecutionResult, - DesktopDisplayInfo, - DesktopHealth, - DesktopPointerPosition, - DesktopProcessErrors, - DesktopProcessLogs, - DesktopProcessRestart, - DesktopProcessStatus, - DesktopProcessStatusList, - DesktopRecordingStatus, - DesktopRecordingSummary, - DesktopWindow, - EditFileResult, - EditResult, - ExecutionLogs, - FileEdit, - FileInfo, - GitCommitResult, - GitResult, - ImageConfig, - LsResult, - LspResponse, - ProcessResult, - PtySession, - Sandbox, - SandboxStatus, - SearchMatch, - Snapshot, - SshAccess, - SshValidation, - StreamEvent, - Template, - TreeEntry, - TreeResult, - sandbox_id_of, - snapshot_id_of, -) - - -# sandbox_id_of / snapshot_id_of - -class TestSandboxIdOf: - def test_from_string(self): - assert sandbox_id_of("sbx-123") == "sbx-123" - - def test_from_sandbox(self): - s = Sandbox(id="sbx-abc") - assert sandbox_id_of(s) == "sbx-abc" - - def test_from_sandbox_status(self): - s = SandboxStatus( - id="sbx-xyz", template_id="t", vcpu=1, memory_mib=512, - disk_mib=10240, state="running", auto_pause=False, created_at="", - ) - assert sandbox_id_of(s) == "sbx-xyz" - - -class TestSnapshotIdOf: - def test_from_string(self): - assert snapshot_id_of("snap-123") == "snap-123" - - def test_from_snapshot(self): - s = Snapshot(snapshot_id="snap-abc", name="my-snap") - assert snapshot_id_of(s) == "snap-abc" - - -# Sandbox - -class TestSandbox: - def test_full_dict(self): - data = { - "id": "sbx-1", - "template_id": "tpl-1", - "vcpu": 2, - "memory_mib": 2048, - "disk_mib": 10240, - "state": "running", - "auto_pause": True, - "created_at": "2025-01-01", - "network_policy": {"mode": "allow-all"}, - } - s = Sandbox.from_dict(data) # type: ignore[arg-type] - assert s.id == "sbx-1" - assert s.template_id == "tpl-1" - assert s.vcpu == 2 - assert s.memory_mib == 2048 - assert s.disk_mib == 10240 - assert s.state == "running" - assert s.auto_pause is True - assert s.created_at == "2025-01-01" - assert s.network_policy == {"mode": "allow-all"} - - def test_minimal_dict(self): - data = {"id": "sbx-2"} - s = Sandbox.from_dict(data) # type: ignore[arg-type] - assert s.id == "sbx-2" - assert s.template_id == "" - assert s.vcpu == 0 - assert s.state == "starting" - assert s.auto_pause is False - assert s.network_policy is None - - -# SandboxStatus - -class TestSandboxStatus: - def test_full_dict(self): - data = { - "id": "sbx-1", - "template_id": "tpl-1", - "vcpu": 4, - "memory_mib": 4096, - "disk_mib": 10240, - "state": "paused", - "auto_pause": True, - "created_at": "2025-01-01", - } - s = SandboxStatus.from_dict(data) # type: ignore[arg-type] - assert s.id == "sbx-1" - assert s.state == "paused" - assert s.vcpu == 4 - - def test_tolerant_parsing_missing_keys(self): - """SandboxStatus.from_dict should not raise on missing keys.""" - data = {"id": "sbx-3"} - s = SandboxStatus.from_dict(data) # type: ignore[arg-type] - assert s.id == "sbx-3" - assert s.template_id == "" - assert s.vcpu == 0 - assert s.memory_mib == 0 - assert s.disk_mib == 0 - assert s.state == "starting" - assert s.auto_pause is False - assert s.created_at == "" - - def test_empty_dict(self): - s = SandboxStatus.from_dict({}) # type: ignore[arg-type] - assert s.id == "" - assert s.state == "starting" - - -# Snapshot - -class TestSnapshot: - def test_id_property(self): - s = Snapshot(snapshot_id="snap-1", name="test") - assert s.id == "snap-1" - - def test_from_dict_full(self): - data = { - "snapshot_id": "snap-1", - "name": "my-snap", - "template_id": "tpl-1", - "vcpu": 2, - "memory_mib": 1024, - "disk_mib": 10240, - "network_policy": {"mode": "deny-all"}, - "created_at": "2025-01-01", - } - s = Snapshot.from_dict(data) # type: ignore[arg-type] - assert s.snapshot_id == "snap-1" - assert s.name == "my-snap" - assert s.network_policy == {"mode": "deny-all"} - - def test_from_dict_minimal(self): - s = Snapshot.from_dict({}) # type: ignore[arg-type] - assert s.snapshot_id == "" - assert s.name == "" - - -# FileInfo / LsResult - -class TestFileInfo: - def test_full_dict(self): - data = { - "name": "main.py", - "path": "/workspace/main.py", - "is_dir": False, - "size": 1234, - "mode": "644", - "mtime": 1700000000, - "owner": "root", - "group": "root", - "is_symlink": True, - "link_target": "/usr/bin/python", - } - f = FileInfo.from_dict(data) # type: ignore[arg-type] - assert f.name == "main.py" - assert f.size == 1234 - assert f.is_symlink is True - assert f.link_target == "/usr/bin/python" - - def test_empty_dict(self): - f = FileInfo.from_dict({}) # type: ignore[arg-type] - assert f.name == "" - assert f.path == "" - assert f.is_dir is False - assert f.size == 0 - - -class TestLsResult: - def test_from_dict(self): - data = {"items": [{"name": "a.py", "path": "/a.py"}, {"name": "b.py", "path": "/b.py"}]} - r = LsResult.from_dict(data) # type: ignore[arg-type] - assert len(r.items) == 2 - assert r.items[0].name == "a.py" - - def test_empty_items(self): - r = LsResult.from_dict({"items": []}) # type: ignore[arg-type] - assert r.items == [] - - def test_missing_items(self): - r = LsResult.from_dict({}) # type: ignore[arg-type] - assert r.items == [] - - -# FileEdit - -class TestFileEdit: - def test_to_dict(self): - e = FileEdit(find="hello", replace="world") - assert e.to_dict() == {"find": "hello", "replace": "world"} - - def test_to_dict_empty_replace(self): - e = FileEdit(find="delete_me") - assert e.to_dict() == {"find": "delete_me", "replace": ""} - - -# EditFileResult / EditResult - -class TestEditFileResult: - def test_from_dict(self): - r = EditFileResult.from_dict({"diff": "--- a\n+++ b", "replacements": 3}) # type: ignore[arg-type] - assert r.diff == "--- a\n+++ b" - assert r.replacements == 3 - - def test_empty_dict(self): - r = EditFileResult.from_dict({}) # type: ignore[arg-type] - assert r.diff == "" - assert r.replacements == 0 - - -class TestEditResult: - def test_from_dict(self): - r = EditResult.from_dict({"file": "a.py", "success": True, "error": ""}) # type: ignore[arg-type] - assert r.file == "a.py" - assert r.success is True - - -# SearchMatch - -class TestSearchMatch: - def test_from_dict(self): - m = SearchMatch.from_dict({"path": "/a.py", "line": 42, "content": "TODO"}) # type: ignore[arg-type] - assert m.path == "/a.py" - assert m.line == 42 - assert m.content == "TODO" - - def test_empty_dict(self): - m = SearchMatch.from_dict({}) # type: ignore[arg-type] - assert m.line == 0 - - -# TreeEntry / TreeResult - -class TestTreeEntry: - def test_from_dict_with_children(self): - data = { - "name": "src", - "type": "directory", - "children": [ - {"name": "main.py", "type": "file"}, - ], - } - t = TreeEntry.from_dict(data) # type: ignore[arg-type] - assert t.name == "src" - assert t.type == "directory" - assert len(t.children) == 1 - assert t.children[0].name == "main.py" - assert t.children[0].type == "file" - assert t.children[0].children == [] - - def test_empty_dict(self): - t = TreeEntry.from_dict({}) # type: ignore[arg-type] - assert t.name == "" - assert t.type == "file" - assert t.children == [] - - -class TestTreeResult: - def test_from_dict(self): - r = TreeResult.from_dict({"items": [{"name": "a", "type": "file"}]}) # type: ignore[arg-type] - assert len(r.items) == 1 - - def test_missing_items(self): - r = TreeResult.from_dict({}) # type: ignore[arg-type] - assert r.items == [] - - -# GitResult / GitCommitResult - -class TestGitResult: - def test_from_dict(self): - r = GitResult.from_dict({"output": "ok", "exit_code": 0}) # type: ignore[arg-type] - assert r.output == "ok" - assert r.exit_code == 0 - - def test_empty_dict(self): - r = GitResult.from_dict({}) # type: ignore[arg-type] - assert r.output == "" - assert r.exit_code == 0 - - -class TestGitCommitResult: - def test_with_result(self): - data = {"sha": "abc123", "result": {"output": "committed", "exit_code": 0}} - r = GitCommitResult.from_dict(data) # type: ignore[arg-type] - assert r.sha == "abc123" - assert r.result is not None - assert r.result.output == "committed" - - def test_without_result(self): - r = GitCommitResult.from_dict({"sha": "abc"}) # type: ignore[arg-type] - assert r.sha == "abc" - assert r.result is None - - def test_null_result(self): - r = GitCommitResult.from_dict({"sha": None, "result": None}) # type: ignore[arg-type] - assert r.sha is None - assert r.result is None - - -# ProcessResult - -class TestProcessResult: - def test_from_dict(self): - r = ProcessResult.from_dict({"exit_code": 1, "result": "error output"}) # type: ignore[arg-type] - assert r.exit_code == 1 - assert r.result == "error output" - - -# SshAccess / SshValidation - -class TestSshAccess: - def test_from_dict(self): - data = { - "id": "ssh-1", - "password": "secret", - "ssh_command": "ssh user@host", - "sandbox_id": "sbx-1", - "expires_at": "2025-12-31", - "created_at": "2025-01-01", - "updated_at": "2025-01-01", - } - s = SshAccess.from_dict(data) # type: ignore[arg-type] - assert s.id == "ssh-1" - assert s.password == "secret" - assert s.ssh_command == "ssh user@host" - - -class TestSshValidation: - def test_from_dict(self): - v = SshValidation.from_dict({"valid": True, "sandbox_id": "sbx-1"}) # type: ignore[arg-type] - assert v.valid is True - assert v.sandbox_id == "sbx-1" - - def test_empty_dict(self): - v = SshValidation.from_dict({}) # type: ignore[arg-type] - assert v.valid is False - assert v.sandbox_id == "" - - -# PtySession - -class TestPtySession: - def test_from_dict_with_id(self): - data = {"id": "pty_1", "cwd": "/home/user", "cols": 80, "rows": 24, "active": True} - p = PtySession.from_dict(data) # type: ignore[arg-type] - assert p.id == "pty_1" - assert p.cwd == "/home/user" - assert p.cols == 80 - assert p.active is True - - def test_from_dict_with_session_id(self): - """Should fall back to session_id when id is missing.""" - data = {"session_id": "pty_2", "cols": 120, "rows": 40} - p = PtySession.from_dict(data) # type: ignore[arg-type] - assert p.id == "pty_2" - - def test_prefers_id_over_session_id(self): - data = {"id": "pty_a", "session_id": "pty_b"} - p = PtySession.from_dict(data) # type: ignore[arg-type] - assert p.id == "pty_a" - - def test_empty_dict(self): - p = PtySession.from_dict({}) # type: ignore[arg-type] - assert p.id == "" - assert p.envs == {} - assert p.lazy_start is False - - -# LspResponse - -class TestLspResponse: - def test_from_dict(self): - r = LspResponse.from_dict({"success": True}) # type: ignore[arg-type] - assert r.success is True - - def test_empty_dict(self): - r = LspResponse.from_dict({}) # type: ignore[arg-type] - assert r.success is False - - -# ImageConfig - -class TestImageConfig: - def test_from_dict_full(self): - data = { - "entrypoint": ["/bin/sh"], - "cmd": ["-c", "echo hi"], - "working_dir": "/workspace", - "user": "appuser", - "env": {"PATH": "/usr/bin"}, - } - c = ImageConfig.from_dict(data) # type: ignore[arg-type] - assert c.entrypoint == ["/bin/sh"] - assert c.cmd == ["-c", "echo hi"] - assert c.working_dir == "/workspace" - assert c.user == "appuser" - assert c.env == {"PATH": "/usr/bin"} - - def test_null_lists(self): - data = {"entrypoint": None, "cmd": None} - c = ImageConfig.from_dict(data) # type: ignore[arg-type] - assert c.entrypoint == [] - assert c.cmd == [] - assert c.user == "" - - -# Template - -class TestTemplate: - def test_from_dict_full(self): - data = { - "id": "tpl-1", - "name": "my-template", - "digest": "sha256:abc", - "image_config": {"entrypoint": ["/bin/sh"]}, - "is_system": False, - "created_at": "2025-01-01", - } - t = Template.from_dict(data) # type: ignore[arg-type] - assert t.id == "tpl-1" - assert t.name == "my-template" - assert t.image_config is not None - assert t.image_config.entrypoint == ["/bin/sh"] - - def test_from_dict_null_image_config(self): - data = {"id": "tpl-2", "name": "t2", "digest": "", "image_config": None, "is_system": True, "created_at": ""} - t = Template.from_dict(data) # type: ignore[arg-type] - assert t.image_config is None - assert t.is_system is True - - -# CodeExecutionOutput - -class TestCodeExecutionOutput: - def test_from_dict_full(self): - data = { - "is_primary": True, - "text": "hello", - "png": base64.b64encode(b"PNG_DATA").decode(), - "svg": "", - "html": "

hi

", - "markdown": "# hi", - "json": {"key": "val"}, - "jpeg": base64.b64encode(b"JPEG_DATA").decode(), - "pdf": base64.b64encode(b"PDF_DATA").decode(), - "latex": "\\frac{1}{2}", - "javascript": "console.log('hi')", - "extra": {"custom": True}, - } - o = CodeExecutionOutput.from_dict(data) # type: ignore[arg-type] - assert o.is_primary is True - assert o.is_main_result is True - assert o.text == "hello" - assert o.json_data == {"key": "val"} - assert o.extra == {"custom": True} - - def test_is_main_result_alias(self): - """is_main_result should accept is_main_result key from dict.""" - data = {"is_main_result": True} - o = CodeExecutionOutput.from_dict(data) # type: ignore[arg-type] - assert o.is_primary is True - assert o.is_main_result is True - - def test_png_bytes(self): - raw = b"PNG_DATA" - o = CodeExecutionOutput(png=base64.b64encode(raw).decode()) - assert o.png_bytes() == raw - - def test_png_bytes_none(self): - o = CodeExecutionOutput() - assert o.png_bytes() is None - - def test_jpeg_bytes(self): - raw = b"JPEG" - o = CodeExecutionOutput(jpeg=base64.b64encode(raw).decode()) - assert o.jpeg_bytes() == raw - - def test_jpeg_bytes_none(self): - o = CodeExecutionOutput() - assert o.jpeg_bytes() is None - - def test_pdf_bytes(self): - raw = b"PDF" - o = CodeExecutionOutput(pdf=base64.b64encode(raw).decode()) - assert o.pdf_bytes() == raw - - def test_pdf_bytes_none(self): - o = CodeExecutionOutput() - assert o.pdf_bytes() is None - - def test_empty_dict(self): - o = CodeExecutionOutput.from_dict({}) # type: ignore[arg-type] - assert o.is_primary is False - assert o.text is None - assert o.png is None - - -# CodeExecutionError - -class TestCodeExecutionError: - def test_from_dict(self): - e = CodeExecutionError.from_dict({"name": "ValueError", "value": "bad", "traceback": "line 1"}) # type: ignore[arg-type] - assert e.name == "ValueError" - assert e.value == "bad" - assert e.traceback == "line 1" - - def test_empty_dict(self): - e = CodeExecutionError.from_dict({}) # type: ignore[arg-type] - assert e.name == "" - - -# ExecutionLogs - -class TestExecutionLogs: - def test_from_dict(self): - logs = ExecutionLogs.from_dict({"stdout": ["hello"], "stderr": ["oops"]}) # type: ignore[arg-type] - assert logs.stdout == ["hello"] - assert logs.stderr == ["oops"] - - def test_null_lists(self): - logs = ExecutionLogs.from_dict({"stdout": None, "stderr": None}) # type: ignore[arg-type] - assert logs.stdout == [] - assert logs.stderr == [] - - def test_empty_dict(self): - logs = ExecutionLogs.from_dict({}) # type: ignore[arg-type] - assert logs.stdout == [] - - -# CodeExecutionResult - -class TestCodeExecutionResult: - def test_main_text_primary(self): - """main_text should return the primary item's text.""" - r = CodeExecutionResult.from_dict({ - "items": [ - {"text": "secondary", "is_primary": False}, - {"text": "primary", "is_primary": True}, - ], - "logs": {}, - "error": None, - "execution_count": 1, - }) # type: ignore[arg-type] - assert r.main_text == "primary" - - def test_main_text_fallback(self): - """main_text should fall back to last item when no primary.""" - r = CodeExecutionResult.from_dict({ - "items": [ - {"text": "first"}, - {"text": "last"}, - ], - "logs": {}, - "error": None, - "execution_count": 1, - }) # type: ignore[arg-type] - assert r.main_text == "last" - - def test_main_text_empty(self): - r = CodeExecutionResult.from_dict({ - "items": [], - "logs": {}, - "error": None, - "execution_count": 0, - }) # type: ignore[arg-type] - assert r.main_text is None - - def test_with_error(self): - r = CodeExecutionResult.from_dict({ - "items": [], - "logs": {"stdout": ["out"]}, - "error": {"name": "Err", "value": "msg", "traceback": "tb"}, - "execution_count": 1, - }) # type: ignore[arg-type] - assert r.error is not None - assert r.error.name == "Err" - assert r.logs.stdout == ["out"] - - def test_context_id_passthrough(self): - r = CodeExecutionResult.from_dict( - {"items": [], "logs": {}, "error": None, "execution_count": 0}, - context_id="ctx_1", - ) # type: ignore[arg-type] - assert r.context_id == "ctx_1" - - -# StreamEvent - -class TestStreamEvent: - def test_integer_type_stdout(self): - e = StreamEvent.from_dict({"type": 0, "data": "hello"}) # type: ignore[arg-type] - assert e.type == "stdout" - - def test_integer_type_stderr(self): - e = StreamEvent.from_dict({"type": 1, "data": "err"}) # type: ignore[arg-type] - assert e.type == "stderr" - - def test_integer_type_exit(self): - e = StreamEvent.from_dict({"type": 2, "data": "", "code": 0}) # type: ignore[arg-type] - assert e.type == "exit" - assert e.code == 0 - - def test_integer_type_error(self): - e = StreamEvent.from_dict({"type": 3, "data": "bad"}) # type: ignore[arg-type] - assert e.type == "error" - - def test_string_type(self): - e = StreamEvent.from_dict({"type": "stdout", "data": "hi"}) # type: ignore[arg-type] - assert e.type == "stdout" - - def test_unknown_integer_type(self): - e = StreamEvent.from_dict({"type": 99, "data": ""}) # type: ignore[arg-type] - assert e.type == "99" - - def test_empty_dict(self): - e = StreamEvent.from_dict({}) # type: ignore[arg-type] - assert e.type == "" - assert e.data == "" - assert e.code is None - - -# CodeContext - -class TestCodeContext: - def test_integer_language_python(self): - c = CodeContext.from_dict({"id": "ctx_1", "language": 1, "cwd": "/home"}) # type: ignore[arg-type] - assert c.id == "ctx_1" - assert c.language == "python" - - def test_integer_language_typescript(self): - c = CodeContext.from_dict({"id": "ctx_2", "language": 2}) # type: ignore[arg-type] - assert c.language == "typescript" - - def test_string_language(self): - c = CodeContext.from_dict({"id": "ctx_3", "language": "python"}) # type: ignore[arg-type] - assert c.language == "python" - - def test_context_id_fallback(self): - """Should fall back to context_id when id is missing.""" - c = CodeContext.from_dict({"context_id": "ctx_4"}) # type: ignore[arg-type] - assert c.id == "ctx_4" - - def test_prefers_id_over_context_id(self): - c = CodeContext.from_dict({"id": "ctx_a", "context_id": "ctx_b"}) # type: ignore[arg-type] - assert c.id == "ctx_a" - - def test_empty_dict(self): - c = CodeContext.from_dict({}) # type: ignore[arg-type] - assert c.id == "" - assert c.language == "" - - -# Desktop models - -class TestDesktopHealth: - def test_with_state(self): - h = DesktopHealth.from_dict({"ok": True, "state": "ready"}) # type: ignore[arg-type] - assert h.ok is True - assert h.state == "ready" - - def test_missing_state(self): - h = DesktopHealth.from_dict({"ok": False}) # type: ignore[arg-type] - assert h.ok is False - assert h.state == "" - - def test_empty_dict(self): - h = DesktopHealth.from_dict({}) # type: ignore[arg-type] - assert h.ok is False - assert h.state == "" - - -class TestDesktopDisplayInfo: - def test_from_dict(self): - d = DesktopDisplayInfo.from_dict({"display": ":0", "width": 1920, "height": 1080}) # type: ignore[arg-type] - assert d.display == ":0" - assert d.width == 1920 - assert d.height == 1080 - - -class TestDesktopWindow: - def test_class_key(self): - w = DesktopWindow.from_dict({"id": "w1", "class": "Firefox"}) # type: ignore[arg-type] - assert w.window_class == "Firefox" - - def test_class_underscore_key(self): - w = DesktopWindow.from_dict({"id": "w2", "class_": "Chrome"}) # type: ignore[arg-type] - assert w.window_class == "Chrome" - - def test_prefers_class_over_class_(self): - w = DesktopWindow.from_dict({"id": "w3", "class": "A", "class_": "B"}) # type: ignore[arg-type] - assert w.window_class == "A" - - def test_empty_dict(self): - w = DesktopWindow.from_dict({}) # type: ignore[arg-type] - assert w.window_class == "" - - -class TestDesktopPointerPosition: - def test_from_dict(self): - p = DesktopPointerPosition.from_dict({"x": 100, "y": 200}) # type: ignore[arg-type] - assert p.x == 100 - assert p.y == 200 - - -class TestDesktopRecordingStatus: - def test_from_dict(self): - data = { - "id": "rec_1", - "active": True, - "started_at": "2025-01-01", - "download": "/api/recordings/rec_1/download", - "mime_type": "video/mp4", - "file_name": "recording.mp4", - "display": ":0", - "resolution": "1920x1080", - } - r = DesktopRecordingStatus.from_dict(data) # type: ignore[arg-type] - assert r.id == "rec_1" - assert r.active is True - - -class TestDesktopRecordingSummary: - def test_from_dict(self): - data = {"id": "rec_1", "file_name": "a.mp4", "size_bytes": 1024} - r = DesktopRecordingSummary.from_dict(data) # type: ignore[arg-type] - assert r.size_bytes == 1024 - - -class TestDesktopProcessStatus: - def test_from_dict(self): - p = DesktopProcessStatus.from_dict({"name": "xvfb", "running": True, "pid": 123}) # type: ignore[arg-type] - assert p.name == "xvfb" - assert p.running is True - assert p.pid == 123 - - -class TestDesktopProcessStatusList: - def test_from_dict(self): - data = { - "status": "running", - "items": [{"name": "xvfb", "running": True, "pid": 1}], - "running": 1, - "total": 4, - } - status_list = DesktopProcessStatusList.from_dict(data) # type: ignore[arg-type] - assert status_list.status == "running" - assert len(status_list.items) == 1 - assert status_list.running == 1 - assert status_list.total == 4 - - def test_empty_dict(self): - status_list = DesktopProcessStatusList.from_dict({}) # type: ignore[arg-type] - assert status_list.items == [] - - -class TestDesktopProcessRestart: - def test_with_status(self): - data = {"message": "restarted", "status": {"name": "xvfb", "running": True, "pid": 42}} - r = DesktopProcessRestart.from_dict(data) # type: ignore[arg-type] - assert r.message == "restarted" - assert r.status is not None - assert r.status.pid == 42 - - def test_without_status(self): - r = DesktopProcessRestart.from_dict({"message": "ok"}) # type: ignore[arg-type] - assert r.status is None - - -class TestDesktopProcessLogs: - def test_from_dict(self): - process_logs = DesktopProcessLogs.from_dict({"process": "xvfb", "logs": "output..."}) # type: ignore[arg-type] - assert process_logs.process == "xvfb" - assert process_logs.logs == "output..." - - -class TestDesktopProcessErrors: - def test_from_dict(self): - e = DesktopProcessErrors.from_dict({"process": "x11vnc", "errors": "fail"}) # type: ignore[arg-type] - assert e.process == "x11vnc" - assert e.errors == "fail" diff --git a/tests/test_process.py b/tests/test_process.py new file mode 100644 index 0000000..16ffb3f --- /dev/null +++ b/tests/test_process.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from leap0.process import ProcessClient + + +class TestProcessClient: + def test_execute(self, mock_transport): + mock_transport.request_json.return_value = {"exit_code": 0, "result": "hello"} + result = ProcessClient(mock_transport).execute("sbx-1", command="echo hello") + assert result.exit_code == 0 + assert result.result == "hello" + assert "/process/execute" in mock_transport.request_json.call_args[0][1] diff --git a/tests/test_sandboxes.py b/tests/test_sandboxes.py new file mode 100644 index 0000000..46a912d --- /dev/null +++ b/tests/test_sandboxes.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +from leap0.sandboxes import SandboxesClient +from leap0.common.sandbox import Sandbox + + +class TestSandboxesClient: + def test_create(self, mock_transport): + mock_transport.request_json.return_value = { + "id": "sbx-1", "template_id": "tpl-1", "vcpu": 2, "memory_mib": 2048, + "disk_mib": 10240, "state": "starting", "auto_pause": False, "created_at": "", + } + result = SandboxesClient(mock_transport, sandbox_domain="s.dev").create(template_name="my-tpl", vcpu=2, memory_mib=2048) + args, kwargs = mock_transport.request_json.call_args + assert args == ("POST", "/v1/sandbox") + assert kwargs["json"]["template_name"] == "my-tpl" + assert result.id == "sbx-1" + + def test_get(self, mock_transport): + mock_transport.request_json.return_value = { + "id": "sbx-1", "template_id": "t", "vcpu": 1, "memory_mib": 512, + "disk_mib": 10240, "state": "running", "auto_pause": False, "created_at": "", + } + SandboxesClient(mock_transport, sandbox_domain="s.dev").get("sbx-1") + assert mock_transport.request_json.call_args[0][1] == "/v1/sandbox/sbx-1/" + + def test_delete(self, mock_transport): + mock_transport.request.return_value = MagicMock(status_code=204) + SandboxesClient(mock_transport, sandbox_domain="s.dev").delete("sbx-1") + assert mock_transport.request.call_args[1]["expected_status"] == 204 + + def test_accepts_sandbox_object(self, mock_transport): + mock_transport.request_json.return_value = { + "id": "sbx-1", "template_id": "t", "vcpu": 1, "memory_mib": 512, + "disk_mib": 10240, "state": "running", "auto_pause": False, "created_at": "", + } + SandboxesClient(mock_transport, sandbox_domain="s.dev").get(Sandbox(id="sbx-obj")) + assert "sbx-obj" in mock_transport.request_json.call_args[0][1] + + def test_invoke_url(self, mock_transport): + assert SandboxesClient(mock_transport, sandbox_domain="sandbox.leap0.dev").invoke_url("sbx-1", "/api/health") == "https://sbx-1.sandbox.leap0.dev/api/health" diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py new file mode 100644 index 0000000..cd0c327 --- /dev/null +++ b/tests/test_snapshots.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +from leap0.snapshots import SnapshotsClient +from leap0.common.snapshot import Snapshot + + +class TestSnapshotsClient: + def test_create(self, mock_transport): + mock_transport.request_json.return_value = { + "snapshot_id": "snap-1", "name": "s", "template_id": "t", + "vcpu": 1, "memory_mib": 512, "disk_mib": 10240, "network_policy": None, "created_at": "", + } + SnapshotsClient(mock_transport).create("sbx-1", name="my-snap") + args, kwargs = mock_transport.request_json.call_args + assert args[1] == "/v1/sandbox/sbx-1/snapshot/create" + assert kwargs["json"]["name"] == "my-snap" + + def test_delete(self, mock_transport): + mock_transport.request.return_value = MagicMock(status_code=204) + SnapshotsClient(mock_transport).delete("snap-1") + assert "snap-1" in mock_transport.request.call_args[0][1] + + def test_delete_accepts_object(self, mock_transport): + mock_transport.request.return_value = MagicMock(status_code=204) + SnapshotsClient(mock_transport).delete(Snapshot(snapshot_id="snap-obj", name="n")) + assert "snap-obj" in mock_transport.request.call_args[0][1] diff --git a/tests/test_ssh.py b/tests/test_ssh.py new file mode 100644 index 0000000..ff862d2 --- /dev/null +++ b/tests/test_ssh.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +from leap0.ssh import SshClient + + +class TestSshClient: + def test_create_access(self, mock_transport): + mock_transport.request_json.return_value = { + "id": "ssh-1", "password": "pw", "ssh_command": "ssh u@h", "sandbox_id": "sbx-1", + } + result = SshClient(mock_transport).create_access("sbx-1") + assert result.id == "ssh-1" + + def test_delete_access(self, mock_transport): + mock_transport.request.return_value = MagicMock(status_code=204) + SshClient(mock_transport).delete_access("sbx-1") + assert "/ssh/access" in mock_transport.request.call_args[0][1] diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..6d6587d --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +from leap0.templates import TemplatesClient + + +class TestTemplatesClient: + def test_create(self, mock_transport): + mock_transport.request_json.return_value = { + "id": "tpl-1", "name": "my-tpl", "digest": "sha256:abc", + "image_config": None, "is_system": False, "created_at": "", + } + result = TemplatesClient(mock_transport).create(name="my-tpl", uri="docker.io/library/python:3.12") + args, kwargs = mock_transport.request_json.call_args + assert args[1] == "/v1/template" + assert result.name == "my-tpl" + + def test_rename(self, mock_transport): + mock_transport.request.return_value = MagicMock(status_code=204) + TemplatesClient(mock_transport).rename("tpl-1", name="new-name") + args, kwargs = mock_transport.request.call_args + assert args == ("PATCH", "/v1/template/tpl-1") + assert kwargs["expected_status"] == 204 + + def test_delete(self, mock_transport): + mock_transport.request.return_value = MagicMock(status_code=204) + TemplatesClient(mock_transport).delete("tpl-1") + assert mock_transport.request.call_args[1]["expected_status"] == 204 diff --git a/tests/test_transport.py b/tests/test_transport.py index 568f9ea..fcc0a71 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,4 +1,4 @@ -"""Tests for Transport: auth, headers, check_response.""" +"""Tests for Transport: auth, headers, check_response, URL handling.""" from __future__ import annotations from unittest.mock import MagicMock @@ -7,99 +7,89 @@ import pytest from leap0._transport import Transport -from leap0.exceptions import Leap0APIError - - -@pytest.fixture -def transport(): - return Transport(api_key="test-key", base_url="https://api.example.com") +from leap0.common.errors import ( + Leap0ConflictError, Leap0Error, Leap0NotFoundError, Leap0PermissionError, Leap0RateLimitError, +) class TestAuthValue: - def test_bearer_prefix_added(self, transport: Transport): + def test_bearer_prefix_added(self, transport): assert transport.auth_value == "Bearer test-key" def test_bearer_not_doubled(self): - t = Transport(api_key="Bearer already", base_url="https://api.example.com") - assert t.auth_value == "Bearer already" - - def test_bearer_case_insensitive(self): - t = Transport(api_key="bearer test", base_url="https://api.example.com") - assert t.auth_value == "bearer test" + assert Transport(api_key="Bearer already", base_url="https://x.com").auth_value == "Bearer already" def test_bearer_disabled(self): - t = Transport(api_key="raw-key", base_url="https://api.example.com", bearer=False) - assert t.auth_value == "raw-key" + assert Transport(api_key="raw", base_url="https://x.com", bearer=False).auth_value == "raw" class TestHeaders: - def test_default_headers(self, transport: Transport): - h = transport.headers() - assert h == {"authorization": "Bearer test-key"} + def test_default(self, transport): + assert transport.headers() == {"authorization": "Bearer test-key"} def test_custom_auth_header(self): - t = Transport(api_key="key", base_url="https://api.example.com", auth_header="leap0-authorization") - h = t.headers() - assert "leap0-authorization" in h - assert h["leap0-authorization"] == "Bearer key" + t = Transport(api_key="key", base_url="https://x.com", auth_header="leap0-authorization") + assert t.headers()["leap0-authorization"] == "Bearer key" - def test_extra_headers_merged(self, transport: Transport): + def test_extra_merged(self, transport): h = transport.headers({"Content-Type": "application/json"}) - assert h["authorization"] == "Bearer test-key" assert h["Content-Type"] == "application/json" - def test_extra_headers_override(self, transport: Transport): - h = transport.headers({"authorization": "override"}) - assert h["authorization"] == "override" - class TestCheckResponse: - def test_pass_on_expected(self, transport: Transport): - resp = MagicMock(spec=httpx.Response) - resp.status_code = 200 - result = transport._check_response(resp, "GET", "/test", 200) - assert result is resp - - def test_pass_on_multiple_expected(self, transport: Transport): - resp = MagicMock(spec=httpx.Response) - resp.status_code = 201 - result = transport._check_response(resp, "POST", "/test", (200, 201)) - assert result is resp - - def test_raise_on_unexpected(self, transport: Transport): - resp = MagicMock(spec=httpx.Response) - resp.status_code = 404 - resp.text = "not found" - with pytest.raises(Leap0APIError) as exc_info: + def test_pass_on_expected(self, transport): + resp = MagicMock(spec=httpx.Response, status_code=200) + assert transport._check_response(resp, "GET", "/test", 200) is resp + + def test_404(self, transport): + resp = MagicMock(spec=httpx.Response, status_code=404, text='{"message":"not found"}', headers={}) + with pytest.raises(Leap0NotFoundError): transport._check_response(resp, "GET", "/test", 200) - assert exc_info.value.status_code == 404 - assert "not found" in str(exc_info.value) - def test_raise_on_500(self, transport: Transport): - resp = MagicMock(spec=httpx.Response) - resp.status_code = 500 - resp.text = "internal error" - with pytest.raises(Leap0APIError) as exc_info: - transport._check_response(resp, "POST", "/create", 201) - assert exc_info.value.status_code == 500 + def test_403(self, transport): + resp = MagicMock(spec=httpx.Response, status_code=403, text='{"message":"denied"}', headers={}) + with pytest.raises(Leap0PermissionError): + transport._check_response(resp, "POST", "/test", 200) + + def test_409(self, transport): + resp = MagicMock(spec=httpx.Response, status_code=409, text="{}", headers={}) + with pytest.raises(Leap0ConflictError): + transport._check_response(resp, "POST", "/test", 200) + + def test_429(self, transport): + resp = MagicMock(spec=httpx.Response, status_code=429, text="", headers={"Retry-After": "30"}) + with pytest.raises(Leap0RateLimitError) as exc_info: + transport._check_response(resp, "GET", "/test", 200) + assert exc_info.value.headers["Retry-After"] == "30" + + def test_500_base_error(self, transport): + resp = MagicMock(spec=httpx.Response, status_code=500, text="err", headers={}) + with pytest.raises(Leap0Error) as exc_info: + transport._check_response(resp, "POST", "/x", 200) + assert type(exc_info.value) is Leap0Error + + def test_json_body_parsed(self, transport): + resp = MagicMock(spec=httpx.Response, status_code=400, + text='{"message":"path cannot be empty"}', headers={}) + with pytest.raises(Leap0Error) as exc_info: + transport._check_response(resp, "POST", "/test", 200) + assert exc_info.value.error_message == "path cannot be empty" + + def test_non_json_body(self, transport): + resp = MagicMock(spec=httpx.Response, status_code=500, text="plain", headers={}) + with pytest.raises(Leap0Error) as exc_info: + transport._check_response(resp, "POST", "/test", 200) + assert exc_info.value.error_message is None class TestTargetUrl: - def test_absolute_url_passthrough(self, transport: Transport): - assert transport._target_url("https://sandbox.example.com/api") == "https://sandbox.example.com/api" + def test_absolute(self, transport): + assert transport._target_url("https://other.com/api") == "https://other.com/api" - def test_relative_path_prepends_base(self, transport: Transport): + def test_relative(self, transport): assert transport._target_url("/v1/sandbox") == "https://api.example.com/v1/sandbox" - def test_http_url_passthrough(self, transport: Transport): - assert transport._target_url("http://local:8080/api") == "http://local:8080/api" - class TestBaseUrlNormalization: def test_trailing_slash_stripped(self): - t = Transport(api_key="key", base_url="https://api.example.com/") - assert t.base_url == "https://api.example.com" - - def test_no_trailing_slash(self): - t = Transport(api_key="key", base_url="https://api.example.com") - assert t.base_url == "https://api.example.com" + assert Transport(api_key="k", base_url="https://api.example.com/").base_url == "https://api.example.com" diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index 08fb81f..0000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Tests for _utils: SSE, NDJSON, URL helpers, base64, file_uri.""" -from __future__ import annotations - -import pytest - -from leap0._utils import ( - b64decode_bytes, - b64decode_text, - b64encode_bytes, - b64encode_text, - ensure_leading_slash, - file_uri, - iter_ndjson, - iter_sse_events, - sandbox_base_url, - websocket_url_from_http, -) - - -# SSE parser - -class TestIterSseEvents: - def test_standard_events(self): - lines = ["data: {\"a\": 1}", "", "data: {\"b\": 2}", ""] - events = list(iter_sse_events(lines)) - assert events == [{"a": 1}, {"b": 2}] - - def test_carriage_return_handling(self): - lines = ["data: {\"x\": 1}\r", "\r", "data: {\"y\": 2}\r", "\r"] - events = list(iter_sse_events(lines)) - assert events == [{"x": 1}, {"y": 2}] - - def test_comment_lines_skipped(self): - lines = [": this is a comment", "data: {\"a\": 1}", "", ":another comment", "data: {\"b\": 2}", ""] - events = list(iter_sse_events(lines)) - assert events == [{"a": 1}, {"b": 2}] - - def test_flush_on_end_without_trailing_blank(self): - """Buffer should flush at end of stream if no trailing empty line.""" - lines = ["data: {\"z\": 99}"] - events = list(iter_sse_events(lines)) - assert events == [{"z": 99}] - - def test_empty_stream(self): - events = list(iter_sse_events([])) - assert events == [] - - def test_only_comments(self): - events = list(iter_sse_events([": comment1", ": comment2"])) - assert events == [] - - def test_only_blank_lines(self): - events = list(iter_sse_events(["", "", ""])) - assert events == [] - - def test_multiline_data(self): - """Multiple data: lines should be joined with newlines before parsing.""" - lines = ['data: {"a":', 'data: 1}', ''] - events = list(iter_sse_events(lines)) - assert events == [{"a": 1}] - - def test_data_with_leading_space_stripped(self): - """Per SSE spec, strip at most one leading space after 'data:'.""" - lines = ["data: {\"s\": 1}", ""] - events = list(iter_sse_events(lines)) - # "data:" + " {\"s\": 1}" -- first space stripped, remainder is " {\"s\": 1}" - # json.loads(" {\"s\": 1}") is valid - assert events == [{"s": 1}] - - def test_non_data_fields_ignored(self): - """event:, id:, retry: fields should be buffered but not treated as data.""" - lines = ["event: update", "id: 42", "retry: 3000", "data: {\"ok\": true}", ""] - events = list(iter_sse_events(lines)) - assert events == [{"ok": True}] - - -# NDJSON parser - -class TestIterNdjson: - def test_standard(self): - lines = ['{"a": 1}', '{"b": 2}'] - events = list(iter_ndjson(lines)) - assert events == [{"a": 1}, {"b": 2}] - - def test_blank_lines_skipped(self): - lines = ['{"a": 1}', '', ' ', '{"b": 2}'] - events = list(iter_ndjson(lines)) - assert events == [{"a": 1}, {"b": 2}] - - def test_whitespace_stripped(self): - lines = [' {"c": 3} '] - events = list(iter_ndjson(lines)) - assert events == [{"c": 3}] - - def test_empty_input(self): - assert list(iter_ndjson([])) == [] - - -# URL utilities - -class TestSandboxBaseUrl: - def test_basic(self): - url = sandbox_base_url("sbx-123", "sandbox.leap0.dev") - assert url == "https://sbx-123.sandbox.leap0.dev" - - def test_with_port(self): - url = sandbox_base_url("sbx-123", "sandbox.leap0.dev", port=8080) - assert url == "https://sbx-123-8080.sandbox.leap0.dev" - - def test_strips_trailing_slash(self): - url = sandbox_base_url("sbx-123", "sandbox.leap0.dev/") - assert url == "https://sbx-123.sandbox.leap0.dev" - - def test_raises_on_missing_domain(self): - with pytest.raises(ValueError, match="sandbox_domain is required"): - sandbox_base_url("sbx-123", None) - - def test_raises_on_empty_domain(self): - with pytest.raises(ValueError, match="sandbox_domain is required"): - sandbox_base_url("sbx-123", "") - - -class TestWebsocketUrlFromHttp: - def test_https_to_wss(self): - assert websocket_url_from_http("https://example.com/ws") == "wss://example.com/ws" - - def test_http_to_ws(self): - assert websocket_url_from_http("http://localhost:8080/ws") == "ws://localhost:8080/ws" - - def test_other_scheme_unchanged(self): - assert websocket_url_from_http("wss://already.ws") == "wss://already.ws" - - def test_no_scheme(self): - assert websocket_url_from_http("example.com/ws") == "example.com/ws" - - -class TestEnsureLeadingSlash: - def test_already_has_slash(self): - assert ensure_leading_slash("/path") == "/path" - - def test_missing_slash(self): - assert ensure_leading_slash("path") == "/path" - - def test_empty_string(self): - assert ensure_leading_slash("") == "/" - - -class TestFileUri: - def test_absolute_path(self): - assert file_uri("/home/user/file.py") == "file:///home/user/file.py" - - def test_relative_path(self): - assert file_uri("home/user/file.py") == "file:///home/user/file.py" - - -# Base64 utilities - -class TestBase64: - def test_bytes_roundtrip(self): - data = b"hello world" - encoded = b64encode_bytes(data) - assert isinstance(encoded, str) - assert b64decode_bytes(encoded) == data - - def test_text_roundtrip(self): - text = "hello world" - encoded = b64encode_text(text) - assert isinstance(encoded, str) - assert b64decode_text(encoded) == text - - def test_text_utf8(self): - text = "unicode: \u00e9\u00e8\u00ea" - encoded = b64encode_text(text, "utf-8") - assert b64decode_text(encoded, "utf-8") == text From c9a666825910f0c9554a07b9425af55b95714ded Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Tue, 31 Mar 2026 16:28:36 -0400 Subject: [PATCH 3/7] folder stucture and errors --- leap0/common/pty.py | 7 +------ leap0/common/template.py | 6 +++--- tests/common/test_pty.py | 26 +++++++++++++++++++++----- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/leap0/common/pty.py b/leap0/common/pty.py index 0adc25d..72e9853 100644 --- a/leap0/common/pty.py +++ b/leap0/common/pty.py @@ -6,13 +6,8 @@ from websockets.sync.client import ClientConnection -class PtyCreateResponseDict(TypedDict, total=False): - session_id: str - - class PtySessionInfoDict(TypedDict, total=False): id: str - session_id: str cwd: str envs: dict[str, str] cols: int @@ -40,7 +35,7 @@ class PtySession: @classmethod def from_dict(cls, data: PtySessionInfoDict) -> PtySession: return cls( - id=data.get("id", data.get("session_id", "")), + id=data.get("id", ""), cwd=data.get("cwd", ""), envs=data.get("envs") or {}, cols=int(data.get("cols", 0)), diff --git a/leap0/common/template.py b/leap0/common/template.py index 10b1a6b..b91e75a 100644 --- a/leap0/common/template.py +++ b/leap0/common/template.py @@ -23,7 +23,7 @@ class RegistryCredentialsDict(TypedDict, total=False): class ImageConfigDict(TypedDict, total=False): entrypoint: list[str] | None cmd: list[str] | None - working_dir: str | None + working_dir: str user: str env: dict[str, Any] | None @@ -41,7 +41,7 @@ class UploadTemplateResponseDict(TypedDict): class ImageConfig: entrypoint: list[str] = field(default_factory=list) cmd: list[str] = field(default_factory=list) - working_dir: str | None = None + working_dir: str = "" user: str = "" env: dict[str, Any] | None = None @@ -50,7 +50,7 @@ def from_dict(cls, data: ImageConfigDict) -> ImageConfig: return cls( entrypoint=data.get("entrypoint") or [], cmd=data.get("cmd") or [], - working_dir=data.get("working_dir"), + working_dir=data.get("working_dir", ""), user=data.get("user", ""), env=data.get("env"), ) diff --git a/tests/common/test_pty.py b/tests/common/test_pty.py index 423ed68..9eefd1c 100644 --- a/tests/common/test_pty.py +++ b/tests/common/test_pty.py @@ -9,11 +9,27 @@ def test_from_dict_with_id(self): assert p.id == "pty_1" assert p.cols == 80 - def test_from_dict_with_session_id(self): - assert PtySession.from_dict({"session_id": "pty_2", "cols": 120}).id == "pty_2" - - def test_prefers_id_over_session_id(self): - assert PtySession.from_dict({"id": "pty_a", "session_id": "pty_b"}).id == "pty_a" + def test_from_dict_with_full_session_info(self): + p = PtySession.from_dict( + { + "id": "pty_2", + "cwd": "/tmp", + "envs": {"TERM": "xterm-256color"}, + "cols": 120, + "rows": 40, + "created_at": "2026-03-31T00:00:00Z", + "active": True, + "lazy_start": False, + } + ) + assert p.id == "pty_2" + assert p.cwd == "/tmp" + assert p.envs == {"TERM": "xterm-256color"} + assert p.cols == 120 + assert p.rows == 40 + assert p.created_at == "2026-03-31T00:00:00Z" + assert p.active is True + assert p.lazy_start is False def test_empty_dict(self): p = PtySession.from_dict({}) From bea0daabd8ce3c556b4f34c3030a88cb524ea140 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Tue, 31 Mar 2026 18:22:52 -0400 Subject: [PATCH 4/7] fix --- leap0/__init__.py | 28 ++++++++++++- leap0/_utils/stream.py | 14 +++++-- leap0/common/code_interpreter.py | 41 ++++++++++++------- leap0/common/lsp.py | 48 ++++++++++++++++++++++- leap0/common/sandbox.py | 45 +++++++++++++++------ leap0/common/snapshot.py | 8 ++-- leap0/common/template.py | 67 ++++++++++++++++++++++---------- leap0/desktop.py | 3 ++ leap0/lsp.py | 20 +++++----- tests/_utils/test_stream.py | 3 ++ tests/common/test_lsp.py | 17 +++++++- tests/common/test_template.py | 4 +- tests/test_import.py | 13 ++++++- 13 files changed, 244 insertions(+), 67 deletions(-) diff --git a/leap0/__init__.py b/leap0/__init__.py index c834af6..9d53197 100644 --- a/leap0/__init__.py +++ b/leap0/__init__.py @@ -1,4 +1,15 @@ from .client import Leap0, Leap0Client +from .code_interpreter import CodeInterpreterClient +from .desktop import DesktopClient +from .filesystem import FilesystemClient +from .git import GitClient +from .lsp import LspClient +from .process import ProcessClient +from .pty import PtyClient +from .sandboxes import SandboxesClient +from .snapshots import SnapshotsClient +from .ssh import SshClient +from .templates import TemplatesClient from .common.config import Leap0Config from .common.errors import ( Leap0ConflictError, @@ -17,7 +28,7 @@ from .common.git import GitCommitResult, GitResult from .common.process import ProcessResult from .common.pty import PtyConnection, PtySession -from .common.lsp import LspResponse +from .common.lsp import LspJsonRpcError, LspJsonRpcErrorDict, LspJsonRpcResponse, LspJsonRpcResponseDict, LspResponse from .common.ssh import SshAccess, SshValidation from .common.template import ImageConfig, Template from .common.code_interpreter import ( @@ -92,6 +103,7 @@ "CodeExecutionOutput", "CodeExecutionResult", "DesktopDisplayInfo", + "DesktopClient", "DesktopHealth", "DesktopPointerPosition", "DesktopProcessErrors", @@ -107,6 +119,8 @@ "ExecutionLogs", "FileEdit", "FileInfo", + "FilesystemClient", + "GitClient", "GitCommitResult", "GitResult", "ImageConfig", @@ -121,20 +135,30 @@ "Leap0TimeoutError", "Leap0WebSocketError", "LsResult", + "LspClient", + "LspJsonRpcError", + "LspJsonRpcResponse", "LspResponse", + "ProcessClient", "ProcessResult", + "PtyClient", "PtyConnection", "PtySession", "Sandbox", + "SandboxesClient", "SandboxStatus", "SearchMatch", "Snapshot", + "SnapshotsClient", + "SshClient", "SshAccess", "SshValidation", "StreamEvent", "Template", + "TemplatesClient", "TreeEntry", "TreeResult", + "CodeInterpreterClient", "CodeContextDict", "CodeExecutionOutputDict", "CodeExecutionResultDict", @@ -158,6 +182,8 @@ "GitResultDict", "ImageConfigDict", "LsResponseDict", + "LspJsonRpcErrorDict", + "LspJsonRpcResponseDict", "LspSuccessResponseDict", "ProcessResultDict", "PtySessionInfoDict", diff --git a/leap0/_utils/stream.py b/leap0/_utils/stream.py index 18c5b89..dc5eaaa 100644 --- a/leap0/_utils/stream.py +++ b/leap0/_utils/stream.py @@ -19,7 +19,15 @@ def _sse_data_value(raw: str) -> str: return value -def iter_sse_events(lines: Iterable[str]) -> Iterator[dict[str, Any]]: +def _parse_sse_data(data: str) -> dict[str, Any] | str: + try: + parsed = json.loads(data) + except json.JSONDecodeError: + return data + return parsed if isinstance(parsed, dict) else data + + +def iter_sse_events(lines: Iterable[str]) -> Iterator[dict[str, Any] | str]: buffer: list[str] = [] for line in lines: stripped = line.rstrip("\r") @@ -27,7 +35,7 @@ def iter_sse_events(lines: Iterable[str]) -> Iterator[dict[str, Any]]: if buffer: data_lines = [_sse_data_value(item) for item in buffer if item.startswith("data:")] if data_lines: - yield json.loads("\n".join(data_lines)) + yield _parse_sse_data("\n".join(data_lines)) buffer.clear() continue if stripped.startswith(":"): @@ -36,4 +44,4 @@ def iter_sse_events(lines: Iterable[str]) -> Iterator[dict[str, Any]]: if buffer: data_lines = [_sse_data_value(item) for item in buffer if item.startswith("data:")] if data_lines: - yield json.loads("\n".join(data_lines)) + yield _parse_sse_data("\n".join(data_lines)) diff --git a/leap0/common/code_interpreter.py b/leap0/common/code_interpreter.py index 8150e0b..446a879 100644 --- a/leap0/common/code_interpreter.py +++ b/leap0/common/code_interpreter.py @@ -2,10 +2,20 @@ import base64 from dataclasses import dataclass, field -from typing import Any, Literal, TypedDict +from enum import Enum +from typing import Any, TypedDict -LanguageLiteral = Literal["python", "typescript"] +class CodeLanguage(str, Enum): + PYTHON = "python" + TYPESCRIPT = "typescript" + + +class StreamEventType(str, Enum): + STDOUT = "stdout" + STDERR = "stderr" + EXIT = "exit" + ERROR = "error" class CodeExecutionOutputDict(TypedDict, total=False): @@ -42,9 +52,6 @@ class CodeExecutionResultDict(TypedDict, total=False): execution_count: int | None -StreamEventTypeLiteral = Literal["stdout", "stderr", "exit", "error"] - - class StreamEventDict(TypedDict, total=False): type: int | str data: str @@ -64,10 +71,15 @@ class ListContextsResponseDict(TypedDict): _LANGUAGE_INT_TO_STR: dict[int, str] = {1: "python", 2: "typescript"} -def _normalize_language(value: int | str | None) -> str: +def _normalize_language(value: int | str | None) -> CodeLanguage | str: if isinstance(value, int): - return _LANGUAGE_INT_TO_STR.get(value, str(value)) - return str(value) if value else "" + value = _LANGUAGE_INT_TO_STR.get(value, str(value)) + if not value: + return "" + try: + return CodeLanguage(str(value)) + except ValueError: + return str(value) @dataclass(slots=True) @@ -174,12 +186,9 @@ def main_text(self) -> str | None: _STREAM_TYPE_INT_TO_STR: dict[int, str] = {0: "stdout", 1: "stderr", 2: "exit", 3: "error"} -StreamEventType = Literal["stdout", "stderr", "exit", "error"] - - @dataclass(slots=True) class StreamEvent: - type: str + type: StreamEventType | str data: str = "" code: int | None = None @@ -190,8 +199,12 @@ def from_dict(cls, data: StreamEventDict) -> StreamEvent: event_type = _STREAM_TYPE_INT_TO_STR.get(raw_type, str(raw_type)) else: event_type = str(raw_type) + try: + parsed_type: StreamEventType | str = StreamEventType(event_type) + except ValueError: + parsed_type = event_type return cls( - type=event_type, + type=parsed_type, data=data.get("data", ""), code=data.get("code"), ) @@ -200,7 +213,7 @@ def from_dict(cls, data: StreamEventDict) -> StreamEvent: @dataclass(slots=True) class CodeContext: id: str - language: str = "" + language: CodeLanguage | str = "" cwd: str = "" @classmethod diff --git a/leap0/common/lsp.py b/leap0/common/lsp.py index 9065434..0cc67dc 100644 --- a/leap0/common/lsp.py +++ b/leap0/common/lsp.py @@ -1,13 +1,26 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TypedDict +from typing import Any, TypedDict class LspSuccessResponseDict(TypedDict, total=False): success: bool +class LspJsonRpcErrorDict(TypedDict, total=False): + code: int + message: str + data: Any + + +class LspJsonRpcResponseDict(TypedDict, total=False): + jsonrpc: str + id: int | str | None + result: Any + error: LspJsonRpcErrorDict + + @dataclass(slots=True) class LspResponse: success: bool = False @@ -15,3 +28,36 @@ class LspResponse: @classmethod def from_dict(cls, data: LspSuccessResponseDict) -> LspResponse: return cls(success=bool(data.get("success", False))) + + +@dataclass(slots=True) +class LspJsonRpcError: + code: int = 0 + message: str = "" + data: Any = None + + @classmethod + def from_dict(cls, data: LspJsonRpcErrorDict) -> LspJsonRpcError: + return cls( + code=int(data.get("code", 0)), + message=data.get("message", ""), + data=data.get("data"), + ) + + +@dataclass(slots=True) +class LspJsonRpcResponse: + jsonrpc: str = "" + id: int | str | None = None + result: Any = None + error: LspJsonRpcError | None = None + + @classmethod + def from_dict(cls, data: LspJsonRpcResponseDict) -> LspJsonRpcResponse: + error = data.get("error") + return cls( + jsonrpc=data.get("jsonrpc", ""), + id=data.get("id"), + result=data.get("result"), + error=LspJsonRpcError.from_dict(error) if isinstance(error, dict) else None, + ) diff --git a/leap0/common/sandbox.py b/leap0/common/sandbox.py index 6112058..8fcfa47 100644 --- a/leap0/common/sandbox.py +++ b/leap0/common/sandbox.py @@ -1,12 +1,26 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Literal, TypedDict +from enum import Enum +from typing import TypedDict from typing_extensions import NotRequired, Required -SandboxState = Literal["starting", "running", "paused", "unpausing", "deleting", "deleted"] +class SandboxState(str, Enum): + STARTING = "starting" + SNAPSHOTTING = "snapshotting" + RUNNING = "running" + PAUSED = "paused" + UNPAUSING = "unpausing" + DELETING = "deleting" + DELETED = "deleted" + + +class NetworkPolicyMode(str, Enum): + ALLOW_ALL = "allow-all" + DENY_ALL = "deny-all" + CUSTOM = "custom" class TransformRuleDict(TypedDict, total=False): @@ -16,7 +30,7 @@ class TransformRuleDict(TypedDict, total=False): class NetworkPolicyDict(TypedDict, total=False): - mode: Required[Literal["allow-all", "deny-all", "custom"]] + mode: Required[NetworkPolicyMode | str] allow_domains: NotRequired[list[str]] allow_cidrs: NotRequired[list[str]] transforms: NotRequired[list[TransformRuleDict]] @@ -28,7 +42,7 @@ class SandboxCreateResponseDict(TypedDict): vcpu: int memory_mib: int disk_mib: int - state: SandboxState + state: SandboxState | str auto_pause: bool created_at: str network_policy: NetworkPolicyDict | None @@ -40,7 +54,7 @@ class SandboxStatusResponseDict(TypedDict): vcpu: int memory_mib: int disk_mib: int - state: SandboxState + state: SandboxState | str auto_pause: bool created_at: str @@ -52,21 +66,21 @@ class Sandbox: vcpu: int = 0 memory_mib: int = 0 disk_mib: int = 0 - state: SandboxState = "starting" + state: SandboxState | str = SandboxState.STARTING auto_pause: bool = False created_at: str = "" network_policy: NetworkPolicyDict | None = None @classmethod def from_dict(cls, data: SandboxCreateResponseDict) -> Sandbox: - state = data.get("state", "starting") + state = _parse_sandbox_state(data.get("state")) return cls( id=data["id"], template_id=data.get("template_id", ""), vcpu=int(data.get("vcpu", 0)), memory_mib=int(data.get("memory_mib", 0)), disk_mib=int(data.get("disk_mib", 0)), - state=state, # type: ignore[arg-type] + state=state, auto_pause=bool(data.get("auto_pause", False)), created_at=data.get("created_at", ""), network_policy=data.get("network_policy"), @@ -80,20 +94,20 @@ class SandboxStatus: vcpu: int memory_mib: int disk_mib: int - state: SandboxState + state: SandboxState | str auto_pause: bool created_at: str @classmethod def from_dict(cls, data: SandboxStatusResponseDict) -> SandboxStatus: - state = data.get("state", "starting") + state = _parse_sandbox_state(data.get("state")) return cls( id=data.get("id", ""), template_id=data.get("template_id", ""), vcpu=int(data.get("vcpu", 0)), memory_mib=int(data.get("memory_mib", 0)), disk_mib=int(data.get("disk_mib", 0)), - state=state, # type: ignore[arg-type] + state=state, auto_pause=bool(data.get("auto_pause", False)), created_at=data.get("created_at", ""), ) @@ -102,6 +116,15 @@ def from_dict(cls, data: SandboxStatusResponseDict) -> SandboxStatus: SandboxRef = str | Sandbox | SandboxStatus +def _parse_sandbox_state(value: SandboxState | str | None) -> SandboxState | str: + if value is None: + return SandboxState.STARTING + try: + return SandboxState(value) + except ValueError: + return str(value) + + def sandbox_id_of(value: SandboxRef) -> str: if isinstance(value, str): return value diff --git a/leap0/common/snapshot.py b/leap0/common/snapshot.py index 359eeb1..96ae738 100644 --- a/leap0/common/snapshot.py +++ b/leap0/common/snapshot.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import TypedDict -from .sandbox import NetworkPolicyDict, SandboxState +from .sandbox import NetworkPolicyDict, SandboxState, _parse_sandbox_state class SnapshotCreateResponseDict(TypedDict, total=False): @@ -13,7 +13,7 @@ class SnapshotCreateResponseDict(TypedDict, total=False): vcpu: int memory_mib: int disk_mib: int - state: SandboxState + state: SandboxState | str created_at: str network_policy: NetworkPolicyDict | None @@ -26,7 +26,7 @@ class Snapshot: vcpu: int = 0 memory_mib: int = 0 disk_mib: int = 0 - state: SandboxState | str = "" + state: SandboxState | str = SandboxState.STARTING network_policy: NetworkPolicyDict | None = None created_at: str = "" @@ -43,7 +43,7 @@ def from_dict(cls, data: SnapshotCreateResponseDict) -> Snapshot: vcpu=int(data.get("vcpu", 0)), memory_mib=int(data.get("memory_mib", 0)), disk_mib=int(data.get("disk_mib", 0)), - state=data.get("state", ""), + state=_parse_sandbox_state(data.get("state")), network_policy=data.get("network_policy"), created_at=data.get("created_at", ""), ) diff --git a/leap0/common/template.py b/leap0/common/template.py index b91e75a..0da56ef 100644 --- a/leap0/common/template.py +++ b/leap0/common/template.py @@ -1,23 +1,50 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any, Literal, TypedDict +from dataclasses import dataclass +from enum import Enum +from typing import TypedDict +from typing_extensions import Literal, NotRequired, Required, TypeAlias -RegistryCredentialType = Literal["basic", "aws", "gcp", "azure"] +class RegistryCredentialType(str, Enum): + BASIC = "basic" + AWS = "aws" + GCP = "gcp" + AZURE = "azure" -class RegistryCredentialsDict(TypedDict, total=False): - type: RegistryCredentialType - username: str - password: str - aws_access_key_id: str - aws_secret_access_key: str - aws_region: str - gcp_service_account_json: str - azure_client_id: str - azure_client_secret: str - azure_tenant_id: str + +class BasicRegistryCredentialsDict(TypedDict, total=False): + type: Required[Literal[RegistryCredentialType.BASIC, "basic"]] + username: Required[str] + password: Required[str] + + +class AwsRegistryCredentialsDict(TypedDict, total=False): + type: Required[Literal[RegistryCredentialType.AWS, "aws"]] + aws_access_key_id: Required[str] + aws_secret_access_key: Required[str] + aws_region: NotRequired[str] + + +class GcpRegistryCredentialsDict(TypedDict, total=False): + type: Required[Literal[RegistryCredentialType.GCP, "gcp"]] + gcp_service_account_json: Required[str] + + +class AzureRegistryCredentialsDict(TypedDict, total=False): + type: Required[Literal[RegistryCredentialType.AZURE, "azure"]] + azure_client_id: Required[str] + azure_client_secret: Required[str] + azure_tenant_id: Required[str] + + +RegistryCredentialsDict: TypeAlias = ( + BasicRegistryCredentialsDict + | AwsRegistryCredentialsDict + | GcpRegistryCredentialsDict + | AzureRegistryCredentialsDict +) class ImageConfigDict(TypedDict, total=False): @@ -25,7 +52,7 @@ class ImageConfigDict(TypedDict, total=False): cmd: list[str] | None working_dir: str user: str - env: dict[str, Any] | None + env: dict[str, str] | None class UploadTemplateResponseDict(TypedDict): @@ -39,17 +66,17 @@ class UploadTemplateResponseDict(TypedDict): @dataclass(slots=True) class ImageConfig: - entrypoint: list[str] = field(default_factory=list) - cmd: list[str] = field(default_factory=list) + entrypoint: list[str] | None = None + cmd: list[str] | None = None working_dir: str = "" user: str = "" - env: dict[str, Any] | None = None + env: dict[str, str] | None = None @classmethod def from_dict(cls, data: ImageConfigDict) -> ImageConfig: return cls( - entrypoint=data.get("entrypoint") or [], - cmd=data.get("cmd") or [], + entrypoint=data.get("entrypoint"), + cmd=data.get("cmd"), working_dir=data.get("working_dir", ""), user=data.get("user", ""), env=data.get("env"), diff --git a/leap0/desktop.py b/leap0/desktop.py index 4cc6253..c4a836c 100644 --- a/leap0/desktop.py +++ b/leap0/desktop.py @@ -9,6 +9,7 @@ from ._utils.errors import intercept_errors from ._utils.stream import iter_sse_events from ._utils.url import sandbox_base_url +from .common.errors import Leap0Error from .common.desktop import ( DesktopDisplayInfo, DesktopDisplayInfoDict, @@ -358,6 +359,8 @@ def status_stream(self, sandbox: SandboxRef) -> Iterator[DesktopProcessStatusLis response = self._transport.stream("GET", url) try: for event in iter_sse_events(response.iter_lines()): + if isinstance(event, str): + raise Leap0Error("Desktop status stream error", body=event) yield DesktopProcessStatusList.from_dict(cast(DesktopProcessStatusListDict, event)) finally: response.close() diff --git a/leap0/lsp.py b/leap0/lsp.py index b572634..c2e440d 100644 --- a/leap0/lsp.py +++ b/leap0/lsp.py @@ -5,7 +5,7 @@ from ._transport import Transport from ._utils.errors import intercept_errors from ._utils.url import file_uri as _file_uri -from .common.lsp import LspResponse, LspSuccessResponseDict +from .common.lsp import LspJsonRpcResponse, LspJsonRpcResponseDict, LspResponse, LspSuccessResponseDict from .common.sandbox import SandboxRef, sandbox_id_of @@ -118,9 +118,9 @@ def completions( uri: str, line: int, character: int, - ) -> dict[str, Any]: - """Returns the raw JSON-RPC 2.0 response from the language server.""" - return self._transport.request_json( + ) -> LspJsonRpcResponse: + """Returns the JSON-RPC 2.0 response from the language server.""" + data: LspJsonRpcResponseDict = self._transport.request_json( # type: ignore[assignment] "POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/lsp/completions", json={ @@ -130,6 +130,7 @@ def completions( "position": {"line": line, "character": character}, }, ) + return LspJsonRpcResponse.from_dict(data) @intercept_errors("Failed to get completions: ") def completions_path( @@ -141,20 +142,21 @@ def completions_path( path: str, line: int, character: int, - ) -> dict[str, Any]: + ) -> LspJsonRpcResponse: """Like :meth:`completions` but accepts a file path instead of a URI.""" return self.completions(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path), line=line, character=character) @intercept_errors("Failed to get document symbols: ") - def document_symbols(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str, uri: str) -> dict[str, Any]: - """Returns the raw JSON-RPC 2.0 response from the language server.""" - return self._transport.request_json( + def document_symbols(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str, uri: str) -> LspJsonRpcResponse: + """Returns the JSON-RPC 2.0 response from the language server.""" + data: LspJsonRpcResponseDict = self._transport.request_json( # type: ignore[assignment] "POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/lsp/document-symbols", json={"language_id": language_id, "path_to_project": path_to_project, "uri": uri}, ) + return LspJsonRpcResponse.from_dict(data) @intercept_errors("Failed to get document symbols: ") - def document_symbols_path(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str, path: str) -> dict[str, Any]: + def document_symbols_path(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str, path: str) -> LspJsonRpcResponse: """Like :meth:`document_symbols` but accepts a file path instead of a URI.""" return self.document_symbols(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path)) diff --git a/tests/_utils/test_stream.py b/tests/_utils/test_stream.py index 89ab426..b64eafc 100644 --- a/tests/_utils/test_stream.py +++ b/tests/_utils/test_stream.py @@ -34,6 +34,9 @@ def test_leading_space_stripped(self): def test_non_data_fields_ignored(self): assert list(iter_sse_events(["event: update", "id: 42", "data: {\"ok\": true}", ""])) == [{"ok": True}] + def test_plain_text_data_preserved(self): + assert list(iter_sse_events(["event: error", "data: desktop stream failed", ""])) == ["desktop stream failed"] + class TestIterNdjson: def test_standard(self): diff --git a/tests/common/test_lsp.py b/tests/common/test_lsp.py index b5f2a3e..6a19e1b 100644 --- a/tests/common/test_lsp.py +++ b/tests/common/test_lsp.py @@ -1,6 +1,6 @@ from __future__ import annotations -from leap0.common.lsp import LspResponse +from leap0.common.lsp import LspJsonRpcResponse, LspResponse class TestLspResponse: @@ -9,3 +9,18 @@ def test_from_dict(self): def test_empty_dict(self): assert LspResponse.from_dict({}).success is False + + +class TestLspJsonRpcResponse: + def test_result_response(self): + response = LspJsonRpcResponse.from_dict({"jsonrpc": "2.0", "id": 1, "result": {"items": []}}) + assert response.jsonrpc == "2.0" + assert response.id == 1 + assert response.result == {"items": []} + assert response.error is None + + def test_error_response(self): + response = LspJsonRpcResponse.from_dict({"jsonrpc": "2.0", "id": 2, "error": {"code": -32602, "message": "Invalid params"}}) + assert response.error is not None + assert response.error.code == -32602 + assert response.error.message == "Invalid params" diff --git a/tests/common/test_template.py b/tests/common/test_template.py index 83ade0e..b2e8613 100644 --- a/tests/common/test_template.py +++ b/tests/common/test_template.py @@ -12,8 +12,8 @@ def test_full(self): def test_null_lists(self): c = ImageConfig.from_dict({"entrypoint": None, "cmd": None}) - assert c.entrypoint == [] - assert c.cmd == [] + assert c.entrypoint is None + assert c.cmd is None class TestTemplate: diff --git a/tests/test_import.py b/tests/test_import.py index 000e3f8..c83415a 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -1,6 +1,17 @@ -from leap0 import Leap0Client +from leap0 import FilesystemClient, GitClient, Leap0Client, LspClient, ProcessClient, PtyClient, SandboxesClient, SnapshotsClient, TemplatesClient def test_client_import() -> None: client = Leap0Client(api_key="test") client.close() + + +def test_service_client_imports() -> None: + assert FilesystemClient is not None + assert GitClient is not None + assert LspClient is not None + assert ProcessClient is not None + assert PtyClient is not None + assert SandboxesClient is not None + assert SnapshotsClient is not None + assert TemplatesClient is not None From 7ef65af15897cd56caccb70c6183d68be6e749c8 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Tue, 31 Mar 2026 18:37:32 -0400 Subject: [PATCH 5/7] fix --- examples/desktop.py | 22 ++------------- leap0/__init__.py | 2 ++ leap0/_utils/errors.py | 48 ++++++++++++++++++++++++-------- leap0/_utils/stream.py | 2 +- leap0/common/code_interpreter.py | 28 +++++++++++++++++-- leap0/common/config.py | 4 +-- leap0/common/desktop.py | 46 +++++++++++++++++++----------- leap0/common/pty.py | 2 +- leap0/common/sandbox.py | 5 +++- leap0/common/template.py | 15 ++++++---- leap0/desktop.py | 44 ++++++++++++++++++++++++++++- leap0/pty.py | 6 +--- pyproject.toml | 1 + tests/test_filesystem.py | 3 +- tests/test_process.py | 2 +- tests/test_ssh.py | 2 +- tests/test_templates.py | 2 +- 17 files changed, 163 insertions(+), 71 deletions(-) diff --git a/examples/desktop.py b/examples/desktop.py index 7cef550..0c9759b 100644 --- a/examples/desktop.py +++ b/examples/desktop.py @@ -1,29 +1,11 @@ from __future__ import annotations import sys -import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from leap0 import Leap0, Leap0Client, Leap0Config, Leap0Error -from leap0.common.sandbox import SandboxRef - - -def wait_for_desktop(client: Leap0Client, sandbox: SandboxRef, *, timeout_seconds: float = 60.0) -> None: - deadline = time.monotonic() + timeout_seconds - while time.monotonic() < deadline: - sandbox_status = client.sandboxes.get(sandbox) - if sandbox_status.state == "running": - try: - health = client.desktop.health(sandbox) - except Leap0Error: - pass - else: - if health.ok and health.state == "ready": - return - time.sleep(0.25) - raise TimeoutError(f"Sandbox {sandbox} did not become ready within {timeout_seconds:.0f}s") +from leap0 import Leap0, Leap0Config, Leap0Error def main() -> None: @@ -31,7 +13,7 @@ def main() -> None: sandbox = client.sandboxes.create(template_name="system/desktop:v0.1.0") try: - wait_for_desktop(client, sandbox) + client.desktop.wait_until_ready(sandbox, timeout=60.0) print("Desktop:", client.desktop.desktop_url(sandbox)) display = client.desktop.display_info(sandbox) diff --git a/leap0/__init__.py b/leap0/__init__.py index 9d53197..20b8880 100644 --- a/leap0/__init__.py +++ b/leap0/__init__.py @@ -180,6 +180,8 @@ "FileInfoDict", "GitCommitResponseDict", "GitResultDict", + "GlobResponseDict", + "GrepResponseDict", "ImageConfigDict", "LsResponseDict", "LspJsonRpcErrorDict", diff --git a/leap0/_utils/errors.py b/leap0/_utils/errors.py index 8fa7aab..386130e 100644 --- a/leap0/_utils/errors.py +++ b/leap0/_utils/errors.py @@ -1,13 +1,39 @@ from __future__ import annotations import functools -from typing import Any, Callable, TypeVar +import inspect +from typing import Any, Callable, Generator, Iterator, TypeVar from ..common.errors import Leap0Error, Leap0TimeoutError F = TypeVar("F", bound=Callable[..., Any]) +def _handle_leap0_error(exc: Leap0Error, message_prefix: str) -> None: + """Apply *message_prefix* to a ``Leap0Error`` in-place.""" + if message_prefix and not exc.message.startswith(message_prefix): + exc.message = f"{message_prefix}{exc.message}" + detail = exc.message + if exc.status_code is not None: + detail = f"{exc.status_code} {detail}" + if exc.error_message: + detail = f"{detail}: {exc.error_message}" + elif exc.body: + detail = f"{detail}: {exc.body}" + exc.args = (detail,) + + +def _wrap_generator(gen: Iterator[Any], message_prefix: str) -> Generator[Any, Any, Any]: + """Yield from *gen* while applying the same error-normalisation logic.""" + try: + yield from gen + except Leap0Error as exc: + _handle_leap0_error(exc, message_prefix) + raise + except Exception as exc: + _raise_wrapped(message_prefix, exc) + + # Error interception decorator def intercept_errors(message_prefix: str = "") -> Callable[[F], F]: """Decorator that catches all exceptions and normalises them into @@ -18,27 +44,25 @@ def intercept_errors(message_prefix: str = "") -> Callable[[F], F]: - ``httpx.TimeoutException`` -- converted to ``Leap0TimeoutError``. - ``httpx.ConnectError`` / ``httpx.NetworkError`` -- converted to ``Leap0Error``. - Any other ``Exception`` -- wrapped in ``Leap0Error``. + + When the decorated function is a generator (or returns an iterator), the + error handling also covers exceptions raised during iteration. """ def decorator(fn: F) -> F: @functools.wraps(fn) def wrapper(*args: Any, **kwargs: Any) -> Any: try: - return fn(*args, **kwargs) + result = fn(*args, **kwargs) except Leap0Error as exc: - if message_prefix and not exc.message.startswith(message_prefix): - exc.message = f"{message_prefix}{exc.message}" - detail = exc.message - if exc.status_code is not None: - detail = f"{exc.status_code} {detail}" - if exc.error_message: - detail = f"{detail}: {exc.error_message}" - elif exc.body: - detail = f"{detail}: {exc.body}" - exc.args = (detail,) + _handle_leap0_error(exc, message_prefix) raise except Exception as exc: _raise_wrapped(message_prefix, exc) + else: + if isinstance(result, (Iterator, Generator)) or inspect.isgenerator(result): + return _wrap_generator(result, message_prefix) + return result return wrapper # type: ignore[return-value] diff --git a/leap0/_utils/stream.py b/leap0/_utils/stream.py index dc5eaaa..fec3d4c 100644 --- a/leap0/_utils/stream.py +++ b/leap0/_utils/stream.py @@ -30,7 +30,7 @@ def _parse_sse_data(data: str) -> dict[str, Any] | str: def iter_sse_events(lines: Iterable[str]) -> Iterator[dict[str, Any] | str]: buffer: list[str] = [] for line in lines: - stripped = line.rstrip("\r") + stripped = line.rstrip("\r\n") if stripped == "": if buffer: data_lines = [_sse_data_value(item) for item in buffer if item.startswith("data:")] diff --git a/leap0/common/code_interpreter.py b/leap0/common/code_interpreter.py index 446a879..4d650ea 100644 --- a/leap0/common/code_interpreter.py +++ b/leap0/common/code_interpreter.py @@ -1,10 +1,14 @@ from __future__ import annotations import base64 +import binascii +import logging from dataclasses import dataclass, field from enum import Enum from typing import Any, TypedDict +_logger = logging.getLogger(__name__) + class CodeLanguage(str, Enum): PYTHON = "python" @@ -119,13 +123,31 @@ def is_main_result(self) -> bool: return self.is_primary def png_bytes(self) -> bytes | None: - return base64.b64decode(self.png) if self.png else None + if not self.png: + return None + try: + return base64.b64decode(self.png) + except (binascii.Error, ValueError): + _logger.debug("Failed to decode png base64 data") + return None def jpeg_bytes(self) -> bytes | None: - return base64.b64decode(self.jpeg) if self.jpeg else None + if not self.jpeg: + return None + try: + return base64.b64decode(self.jpeg) + except (binascii.Error, ValueError): + _logger.debug("Failed to decode jpeg base64 data") + return None def pdf_bytes(self) -> bytes | None: - return base64.b64decode(self.pdf) if self.pdf else None + if not self.pdf: + return None + try: + return base64.b64decode(self.pdf) + except (binascii.Error, ValueError): + _logger.debug("Failed to decode pdf base64 data") + return None @dataclass(slots=True) diff --git a/leap0/common/config.py b/leap0/common/config.py index 9f84643..999dadb 100644 --- a/leap0/common/config.py +++ b/leap0/common/config.py @@ -43,7 +43,7 @@ def __post_init__(self) -> None: self.api_key = os.environ.get("LEAP0_API_KEY") if not self.api_key: raise ValueError("api_key is required or set LEAP0_API_KEY") - if self.base_url is None: + if not self.base_url or not self.base_url.strip(): self.base_url = os.environ.get("LEAP0_BASE_URL") or DEFAULT_BASE_URL - if self.sandbox_domain is None: + if not self.sandbox_domain or not self.sandbox_domain.strip(): self.sandbox_domain = os.environ.get("LEAP0_SANDBOX_DOMAIN") or DEFAULT_SANDBOX_DOMAIN diff --git a/leap0/common/desktop.py b/leap0/common/desktop.py index 76be35f..66d44d3 100644 --- a/leap0/common/desktop.py +++ b/leap0/common/desktop.py @@ -1,7 +1,17 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Literal, TypedDict, cast +from typing import Any, Literal, TypedDict, cast + + +def _safe_int(value: Any, default: int = 0) -> int: + """Parse *value* as an integer, returning *default* on ``None`` or invalid input.""" + if value is None: + return default + try: + return int(value) + except (TypeError, ValueError): + return default class DesktopDisplayInfoDict(TypedDict, total=False): @@ -99,8 +109,8 @@ class DesktopDisplayInfo: def from_dict(cls, data: DesktopDisplayInfoDict) -> DesktopDisplayInfo: return cls( display=data.get("display", ""), - width=int(data.get("width", 0)), - height=int(data.get("height", 0)), + width=_safe_int(data.get("width"), 0), + height=_safe_int(data.get("height"), 0), ) @@ -122,12 +132,12 @@ class DesktopWindow: def from_dict(cls, data: DesktopWindowDict) -> DesktopWindow: return cls( id=data.get("id", ""), - desktop=int(data.get("desktop", 0)), - pid=int(data.get("pid", 0)), - x=int(data.get("x", 0)), - y=int(data.get("y", 0)), - width=int(data.get("width", 0)), - height=int(data.get("height", 0)), + desktop=_safe_int(data.get("desktop"), 0), + pid=_safe_int(data.get("pid"), 0), + x=_safe_int(data.get("x"), 0), + y=_safe_int(data.get("y"), 0), + width=_safe_int(data.get("width"), 0), + height=_safe_int(data.get("height"), 0), window_class=data.get("class", data.get("class_", "")), host=data.get("host", ""), title=data.get("title", ""), @@ -142,7 +152,7 @@ class DesktopPointerPosition: @classmethod def from_dict(cls, data: DesktopPointerPositionDict) -> DesktopPointerPosition: - return cls(x=int(data.get("x", 0)), y=int(data.get("y", 0))) + return cls(x=_safe_int(data.get("x"), 0), y=_safe_int(data.get("y"), 0)) @dataclass(slots=True) @@ -189,7 +199,7 @@ def from_dict(cls, data: DesktopRecordingSummaryDict) -> DesktopRecordingSummary file_name=data.get("file_name", ""), download=data.get("download", ""), mime_type=data.get("mime_type", ""), - size_bytes=int(data.get("size_bytes", 0)), + size_bytes=_safe_int(data.get("size_bytes"), 0), created_at=data.get("created_at", ""), active=bool(data.get("active", False)), ) @@ -217,7 +227,7 @@ def from_dict(cls, data: DesktopProcessStatusDict) -> DesktopProcessStatus: return cls( name=data.get("name", ""), running=bool(data.get("running", False)), - pid=int(data.get("pid", 0)), + pid=_safe_int(data.get("pid"), 0), stdout_log=data.get("stdout_log", ""), stderr_log=data.get("stderr_log", ""), ) @@ -234,9 +244,13 @@ class DesktopProcessStatusList: def from_dict(cls, data: DesktopProcessStatusListDict) -> DesktopProcessStatusList: return cls( status=data.get("status", ""), - items=[DesktopProcessStatus.from_dict(cast(DesktopProcessStatusDict, cast(object, item))) for item in data.get("items", [])], - running=int(data.get("running", 0)), - total=int(data.get("total", 0)), + items=[ + DesktopProcessStatus.from_dict(item) # type: ignore[arg-type] + for item in data.get("items", []) + if isinstance(item, dict) + ], + running=_safe_int(data.get("running"), 0), + total=_safe_int(data.get("total"), 0), ) @@ -250,7 +264,7 @@ def from_dict(cls, data: DesktopProcessRestartDict) -> DesktopProcessRestart: status = data.get("status") return cls( message=data.get("message", ""), - status=DesktopProcessStatus.from_dict(cast(DesktopProcessStatusDict, cast(object, status))) if isinstance(status, dict) else None, + status=DesktopProcessStatus.from_dict(status) if isinstance(status, dict) else None, # type: ignore[arg-type] ) diff --git a/leap0/common/pty.py b/leap0/common/pty.py index 72e9853..52c5988 100644 --- a/leap0/common/pty.py +++ b/leap0/common/pty.py @@ -60,7 +60,7 @@ def recv(self) -> bytes: return message.encode() if isinstance(message, bytes): return message - return b"".join(message) + raise TypeError(f"Unexpected message type from websocket: {type(message).__name__}") def close(self) -> None: self.websocket.close() diff --git a/leap0/common/sandbox.py b/leap0/common/sandbox.py index 8fcfa47..2ae04a3 100644 --- a/leap0/common/sandbox.py +++ b/leap0/common/sandbox.py @@ -73,9 +73,12 @@ class Sandbox: @classmethod def from_dict(cls, data: SandboxCreateResponseDict) -> Sandbox: + sandbox_id = data.get("id") + if not sandbox_id or not isinstance(sandbox_id, str): + raise ValueError(f"Sandbox response missing required non-empty string 'id', got: {sandbox_id!r}") state = _parse_sandbox_state(data.get("state")) return cls( - id=data["id"], + id=sandbox_id, template_id=data.get("template_id", ""), vcpu=int(data.get("vcpu", 0)), memory_mib=int(data.get("memory_mib", 0)), diff --git a/leap0/common/template.py b/leap0/common/template.py index 0da56ef..a7bea1b 100644 --- a/leap0/common/template.py +++ b/leap0/common/template.py @@ -94,12 +94,17 @@ class Template: @classmethod def from_dict(cls, data: UploadTemplateResponseDict) -> Template: + raw_id = data.get("id") # type: ignore[arg-type] + raw_name = data.get("name") # type: ignore[arg-type] + raw_digest = data.get("digest") # type: ignore[arg-type] + raw_created = data.get("created_at") # type: ignore[arg-type] ic = data.get("image_config") + raw_system = data.get("is_system") # type: ignore[arg-type] return cls( - id=data.get("id", ""), # type: ignore[arg-type] - name=data.get("name", ""), # type: ignore[arg-type] - digest=data.get("digest", ""), # type: ignore[arg-type] + id=raw_id if isinstance(raw_id, str) else "", + name=raw_name if isinstance(raw_name, str) else "", + digest=raw_digest if isinstance(raw_digest, str) else "", image_config=ImageConfig.from_dict(ic) if isinstance(ic, dict) else None, - is_system=bool(data.get("is_system", False)), # type: ignore[arg-type] - created_at=data.get("created_at", ""), # type: ignore[arg-type] + is_system=raw_system if isinstance(raw_system, bool) else False, + created_at=raw_created if isinstance(raw_created, str) else "", ) diff --git a/leap0/desktop.py b/leap0/desktop.py index c4a836c..bd598b3 100644 --- a/leap0/desktop.py +++ b/leap0/desktop.py @@ -4,6 +4,7 @@ from typing import Any, cast import httpx +from tenacity import retry, retry_if_exception_type, stop_after_delay, wait_exponential from ._transport import Transport from ._utils.errors import intercept_errors @@ -319,7 +320,7 @@ def delete_recording(self, sandbox: SandboxRef, recording_id: str) -> None: @intercept_errors("Failed to check desktop health: ") def health(self, sandbox: SandboxRef) -> DesktopHealth: """Check the health of the desktop environment.""" - data: DesktopHealthDict = self._request_json("GET", sandbox, "/api/healthz") # type: ignore[assignment] + data: DesktopHealthDict = self._request_json("GET", sandbox, "/api/healthz", expected_status=(200, 503)) # type: ignore[assignment] return DesktopHealth.from_dict(data) @intercept_errors("Failed to get process status: ") @@ -364,3 +365,44 @@ def status_stream(self, sandbox: SandboxRef) -> Iterator[DesktopProcessStatusLis yield DesktopProcessStatusList.from_dict(cast(DesktopProcessStatusListDict, event)) finally: response.close() + + def wait_until_ready(self, sandbox: SandboxRef, *, timeout: float = 60.0) -> None: + """Block until all desktop processes are running. + + Connects to the SSE status stream and waits for the aggregate + status to become ``"running"`` (all four desktop processes alive). + Automatically retries the stream connection on transient errors + using exponential back-off, bounded by *timeout* seconds total. + + Args: + sandbox: Sandbox ID or object. + timeout: Maximum seconds to wait (default 60). + + Raises: + Leap0TimeoutError: If the desktop does not become ready within + *timeout* seconds. + """ + from .common.errors import Leap0TimeoutError + + @retry( + stop=stop_after_delay(timeout), + wait=wait_exponential(multiplier=0.5, min=0.5, max=5), + retry=retry_if_exception_type((Leap0Error, ConnectionError, OSError)), + reraise=True, + ) + def _poll() -> None: + for status in self.status_stream(sandbox): + if status.status == "running": + return + raise Leap0Error("Desktop status stream ended without reaching 'running' state") + + try: + _poll() + except Leap0Error as exc: + raise Leap0TimeoutError( + f"Desktop did not become ready within {timeout:.0f}s: {exc}" + ) from exc + except Exception as exc: + raise Leap0TimeoutError( + f"Desktop did not become ready within {timeout:.0f}s" + ) from exc diff --git a/leap0/pty.py b/leap0/pty.py index 5056911..c34754a 100644 --- a/leap0/pty.py +++ b/leap0/pty.py @@ -7,7 +7,6 @@ from ._transport import Transport from ._utils.errors import intercept_errors from ._utils.url import websocket_url_from_http -from .common.errors import Leap0WebSocketError from .common.pty import PtyConnection, PtyListResponseDict, PtySession, PtySessionInfoDict from .common.sandbox import SandboxRef, sandbox_id_of @@ -97,8 +96,5 @@ def connect(self, sandbox: SandboxRef, session_id: str, **kwargs: Any) -> PtyCon terminal bytes. """ url = self.websocket_url(sandbox, session_id) - try: - websocket = connect(url, additional_headers={self._transport.auth_header: self._transport.auth_value}, **kwargs) - except Exception as exc: - raise Leap0WebSocketError(str(exc)) from exc + websocket = connect(url, additional_headers={self._transport.auth_header: self._transport.auth_value}, **kwargs) return PtyConnection(websocket=websocket) diff --git a/pyproject.toml b/pyproject.toml index 5e71d80..126b478 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ authors = [ ] dependencies = [ "httpx>=0.27,<1", + "tenacity>=8,<10", "websockets>=12,<16", "typing-extensions>=4.0", ] diff --git a/tests/test_filesystem.py b/tests/test_filesystem.py index 4e725d9..1527a1d 100644 --- a/tests/test_filesystem.py +++ b/tests/test_filesystem.py @@ -1,5 +1,7 @@ from __future__ import annotations +from unittest.mock import MagicMock + import pytest from leap0.filesystem import FilesystemClient, _parse_multipart_response @@ -13,7 +15,6 @@ def test_ls(self, mock_transport): assert "/filesystem/ls" in mock_transport.request_json.call_args[0][1] def test_mkdir(self, mock_transport): - from unittest.mock import MagicMock mock_transport.request.return_value = MagicMock(status_code=204) FilesystemClient(mock_transport).mkdir("sbx-1", path="/workspace/src", recursive=True) assert mock_transport.request.call_args[1]["json"]["recursive"] is True diff --git a/tests/test_process.py b/tests/test_process.py index 16ffb3f..5e410fc 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -9,4 +9,4 @@ def test_execute(self, mock_transport): result = ProcessClient(mock_transport).execute("sbx-1", command="echo hello") assert result.exit_code == 0 assert result.result == "hello" - assert "/process/execute" in mock_transport.request_json.call_args[0][1] + assert mock_transport.request_json.call_args[0][:2] == ("POST", "/v1/sandbox/sbx-1/process/execute") diff --git a/tests/test_ssh.py b/tests/test_ssh.py index ff862d2..71911bd 100644 --- a/tests/test_ssh.py +++ b/tests/test_ssh.py @@ -16,4 +16,4 @@ def test_create_access(self, mock_transport): def test_delete_access(self, mock_transport): mock_transport.request.return_value = MagicMock(status_code=204) SshClient(mock_transport).delete_access("sbx-1") - assert "/ssh/access" in mock_transport.request.call_args[0][1] + assert mock_transport.request.call_args[0][:2] == ("DELETE", "/v1/sandbox/sbx-1/ssh/access") diff --git a/tests/test_templates.py b/tests/test_templates.py index 6d6587d..311dc16 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -12,7 +12,7 @@ def test_create(self, mock_transport): "image_config": None, "is_system": False, "created_at": "", } result = TemplatesClient(mock_transport).create(name="my-tpl", uri="docker.io/library/python:3.12") - args, kwargs = mock_transport.request_json.call_args + args, _kwargs = mock_transport.request_json.call_args assert args[1] == "/v1/template" assert result.name == "my-tpl" From 6927859f5a257270c85978125e28eaba9269edf5 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Tue, 31 Mar 2026 18:59:04 -0400 Subject: [PATCH 6/7] fix --- leap0/common/code_interpreter.py | 35 +++++++++++++------------------- leap0/common/config.py | 25 +++++++++++++++++------ leap0/common/desktop.py | 5 ++++- leap0/common/sandbox.py | 7 +++++-- leap0/desktop.py | 8 ++++++-- tests/common/test_sandbox.py | 8 ++++---- 6 files changed, 52 insertions(+), 36 deletions(-) diff --git a/leap0/common/code_interpreter.py b/leap0/common/code_interpreter.py index 4d650ea..7af5305 100644 --- a/leap0/common/code_interpreter.py +++ b/leap0/common/code_interpreter.py @@ -10,6 +10,17 @@ _logger = logging.getLogger(__name__) +def _decode_base64(data: str | None, label: str) -> bytes | None: + """Decode a base64 string, returning ``None`` on missing/invalid input.""" + if not data: + return None + try: + return base64.b64decode(data) + except (binascii.Error, ValueError): + _logger.debug("Failed to decode %s base64 data", label) + return None + + class CodeLanguage(str, Enum): PYTHON = "python" TYPESCRIPT = "typescript" @@ -123,31 +134,13 @@ def is_main_result(self) -> bool: return self.is_primary def png_bytes(self) -> bytes | None: - if not self.png: - return None - try: - return base64.b64decode(self.png) - except (binascii.Error, ValueError): - _logger.debug("Failed to decode png base64 data") - return None + return _decode_base64(self.png, "png") def jpeg_bytes(self) -> bytes | None: - if not self.jpeg: - return None - try: - return base64.b64decode(self.jpeg) - except (binascii.Error, ValueError): - _logger.debug("Failed to decode jpeg base64 data") - return None + return _decode_base64(self.jpeg, "jpeg") def pdf_bytes(self) -> bytes | None: - if not self.pdf: - return None - try: - return base64.b64decode(self.pdf) - except (binascii.Error, ValueError): - _logger.debug("Failed to decode pdf base64 data") - return None + return _decode_base64(self.pdf, "pdf") @dataclass(slots=True) diff --git a/leap0/common/config.py b/leap0/common/config.py index 999dadb..bb6eed5 100644 --- a/leap0/common/config.py +++ b/leap0/common/config.py @@ -39,11 +39,24 @@ class Leap0Config: bearer: bool = True def __post_init__(self) -> None: - if self.api_key is None: - self.api_key = os.environ.get("LEAP0_API_KEY") + # Resolve api_key from env if not provided, then strip and validate. + api_key = self.api_key + if api_key is None: + api_key = os.environ.get("LEAP0_API_KEY") + self.api_key = api_key.strip() if api_key else api_key if not self.api_key: raise ValueError("api_key is required or set LEAP0_API_KEY") - if not self.base_url or not self.base_url.strip(): - self.base_url = os.environ.get("LEAP0_BASE_URL") or DEFAULT_BASE_URL - if not self.sandbox_domain or not self.sandbox_domain.strip(): - self.sandbox_domain = os.environ.get("LEAP0_SANDBOX_DOMAIN") or DEFAULT_SANDBOX_DOMAIN + + # Resolve base_url: strip provided/env value, fall back to default. + base_url = self.base_url.strip() if self.base_url else None + if not base_url: + env_base = os.environ.get("LEAP0_BASE_URL") + base_url = env_base.strip() if env_base else None + self.base_url = base_url or DEFAULT_BASE_URL + + # Resolve sandbox_domain: strip provided/env value, fall back to default. + sandbox_domain = self.sandbox_domain.strip() if self.sandbox_domain else None + if not sandbox_domain: + env_sd = os.environ.get("LEAP0_SANDBOX_DOMAIN") + sandbox_domain = env_sd.strip() if env_sd else None + self.sandbox_domain = sandbox_domain or DEFAULT_SANDBOX_DOMAIN diff --git a/leap0/common/desktop.py b/leap0/common/desktop.py index 66d44d3..e2c6126 100644 --- a/leap0/common/desktop.py +++ b/leap0/common/desktop.py @@ -242,11 +242,14 @@ class DesktopProcessStatusList: @classmethod def from_dict(cls, data: DesktopProcessStatusListDict) -> DesktopProcessStatusList: + raw_items = data.get("items") + if not isinstance(raw_items, (list, tuple)): + raw_items = [] return cls( status=data.get("status", ""), items=[ DesktopProcessStatus.from_dict(item) # type: ignore[arg-type] - for item in data.get("items", []) + for item in raw_items if isinstance(item, dict) ], running=_safe_int(data.get("running"), 0), diff --git a/leap0/common/sandbox.py b/leap0/common/sandbox.py index 2ae04a3..74ebac9 100644 --- a/leap0/common/sandbox.py +++ b/leap0/common/sandbox.py @@ -74,7 +74,7 @@ class Sandbox: @classmethod def from_dict(cls, data: SandboxCreateResponseDict) -> Sandbox: sandbox_id = data.get("id") - if not sandbox_id or not isinstance(sandbox_id, str): + if not isinstance(sandbox_id, str) or not sandbox_id.strip(): raise ValueError(f"Sandbox response missing required non-empty string 'id', got: {sandbox_id!r}") state = _parse_sandbox_state(data.get("state")) return cls( @@ -103,9 +103,12 @@ class SandboxStatus: @classmethod def from_dict(cls, data: SandboxStatusResponseDict) -> SandboxStatus: + sandbox_id = data.get("id") + if not isinstance(sandbox_id, str) or not sandbox_id.strip(): + raise ValueError(f"SandboxStatus response missing required non-empty string 'id', got: {sandbox_id!r}") state = _parse_sandbox_state(data.get("state")) return cls( - id=data.get("id", ""), + id=sandbox_id, template_id=data.get("template_id", ""), vcpu=int(data.get("vcpu", 0)), memory_mib=int(data.get("memory_mib", 0)), diff --git a/leap0/desktop.py b/leap0/desktop.py index bd598b3..9aa2d39 100644 --- a/leap0/desktop.py +++ b/leap0/desktop.py @@ -4,7 +4,7 @@ from typing import Any, cast import httpx -from tenacity import retry, retry_if_exception_type, stop_after_delay, wait_exponential +from tenacity import retry, retry_if_exception, retry_if_exception_type, stop_after_delay, wait_exponential from ._transport import Transport from ._utils.errors import intercept_errors @@ -384,10 +384,14 @@ def wait_until_ready(self, sandbox: SandboxRef, *, timeout: float = 60.0) -> Non """ from .common.errors import Leap0TimeoutError + def _is_transient_leap0(exc: BaseException) -> bool: + """Return True only for a Leap0Error with a 502 status code.""" + return isinstance(exc, Leap0Error) and exc.status_code == 502 + @retry( stop=stop_after_delay(timeout), wait=wait_exponential(multiplier=0.5, min=0.5, max=5), - retry=retry_if_exception_type((Leap0Error, ConnectionError, OSError)), + retry=retry_if_exception_type((ConnectionError, OSError)) | retry_if_exception(_is_transient_leap0), reraise=True, ) def _poll() -> None: diff --git a/tests/common/test_sandbox.py b/tests/common/test_sandbox.py index 54a6252..a527014 100644 --- a/tests/common/test_sandbox.py +++ b/tests/common/test_sandbox.py @@ -40,7 +40,7 @@ def test_full_dict(self): assert s.state == "paused" assert s.vcpu == 4 - def test_empty_dict(self): - s = SandboxStatus.from_dict({}) - assert s.id == "" - assert s.state == "starting" + def test_empty_dict_raises(self): + import pytest + with pytest.raises(ValueError, match="missing required non-empty string 'id'"): + SandboxStatus.from_dict({}) From f74e22431746b22b7373533ff0226fdcff3636f1 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Tue, 31 Mar 2026 19:16:54 -0400 Subject: [PATCH 7/7] fix --- leap0/common/config.py | 53 +++++++++++++++++++++++++++++++----------- leap0/desktop.py | 46 +++++++++++++++++++++++++++--------- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/leap0/common/config.py b/leap0/common/config.py index bb6eed5..5df00ad 100644 --- a/leap0/common/config.py +++ b/leap0/common/config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math import os from dataclasses import dataclass @@ -14,6 +15,22 @@ DEFAULT_CLIENT_TIMEOUT = 300.0 +def _resolve_env_str( + value: str | None, env_var: str, default: str, +) -> str: + """Return *value* (stripped) if non-empty, else the environment variable + *env_var* (stripped), else *default*. + + Handles ``None`` and whitespace-only strings for both the provided + value and the environment variable. + """ + resolved = value.strip() if value else None + if not resolved: + env_val = os.environ.get(env_var) + resolved = env_val.strip() if env_val else None + return resolved or default + + @dataclass(slots=True) class Leap0Config: """Configuration for a Leap0 client. @@ -27,7 +44,8 @@ class Leap0Config: sandbox_domain: Domain suffix used to build per-sandbox URLs. Falls back to the ``LEAP0_SANDBOX_DOMAIN`` environment variable, then to ``sandbox.leap0.dev``. - timeout: Default HTTP timeout in seconds. + timeout: Default HTTP timeout in seconds. Must be a positive, finite + number. auth_header: Name of the header used to send the API key. bearer: When True, the key is sent with a ``Bearer`` prefix. """ @@ -39,6 +57,20 @@ class Leap0Config: bearer: bool = True def __post_init__(self) -> None: + # Validate and normalise timeout early, before it is used downstream. + if self.timeout is None: + raise ValueError("timeout must be a positive number, got None") + try: + self.timeout = float(self.timeout) + except (TypeError, ValueError) as err: + raise ValueError( + f"timeout must be a positive number, got {self.timeout!r}" + ) from err + if self.timeout <= 0 or not math.isfinite(self.timeout): + raise ValueError( + f"timeout must be a positive, finite number, got {self.timeout!r}" + ) + # Resolve api_key from env if not provided, then strip and validate. api_key = self.api_key if api_key is None: @@ -47,16 +79,9 @@ def __post_init__(self) -> None: if not self.api_key: raise ValueError("api_key is required or set LEAP0_API_KEY") - # Resolve base_url: strip provided/env value, fall back to default. - base_url = self.base_url.strip() if self.base_url else None - if not base_url: - env_base = os.environ.get("LEAP0_BASE_URL") - base_url = env_base.strip() if env_base else None - self.base_url = base_url or DEFAULT_BASE_URL - - # Resolve sandbox_domain: strip provided/env value, fall back to default. - sandbox_domain = self.sandbox_domain.strip() if self.sandbox_domain else None - if not sandbox_domain: - env_sd = os.environ.get("LEAP0_SANDBOX_DOMAIN") - sandbox_domain = env_sd.strip() if env_sd else None - self.sandbox_domain = sandbox_domain or DEFAULT_SANDBOX_DOMAIN + self.base_url = _resolve_env_str( + self.base_url, "LEAP0_BASE_URL", DEFAULT_BASE_URL, + ) + self.sandbox_domain = _resolve_env_str( + self.sandbox_domain, "LEAP0_SANDBOX_DOMAIN", DEFAULT_SANDBOX_DOMAIN, + ) diff --git a/leap0/desktop.py b/leap0/desktop.py index 9aa2d39..b887983 100644 --- a/leap0/desktop.py +++ b/leap0/desktop.py @@ -1,16 +1,17 @@ from __future__ import annotations +import time from collections.abc import Iterator from typing import Any, cast import httpx -from tenacity import retry, retry_if_exception, retry_if_exception_type, stop_after_delay, wait_exponential +from tenacity import retry, retry_if_exception, stop_after_delay, wait_exponential from ._transport import Transport from ._utils.errors import intercept_errors from ._utils.stream import iter_sse_events from ._utils.url import sandbox_base_url -from .common.errors import Leap0Error +from .common.errors import Leap0Error, Leap0TimeoutError from .common.desktop import ( DesktopDisplayInfo, DesktopDisplayInfoDict, @@ -354,14 +355,30 @@ def process_errors(self, sandbox: SandboxRef, process_name: str) -> DesktopProce return DesktopProcessErrors.from_dict(data) @intercept_errors("Failed to stream status: ") - def status_stream(self, sandbox: SandboxRef) -> Iterator[DesktopProcessStatusList]: - """Subscribe to a live SSE stream of process status updates.""" + def status_stream(self, sandbox: SandboxRef, *, deadline: float | None = None) -> Iterator[DesktopProcessStatusList]: + """Subscribe to a live SSE stream of process status updates. + + Args: + sandbox: Sandbox ID or object. + deadline: Absolute ``time.monotonic()`` deadline. When set, a + :class:`Leap0TimeoutError` is raised once the deadline is + exceeded. ``None`` means no deadline. + """ url = f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}/api/status/stream" response = self._transport.stream("GET", url) try: for event in iter_sse_events(response.iter_lines()): - if isinstance(event, str): - raise Leap0Error("Desktop status stream error", body=event) + if deadline is not None and time.monotonic() >= deadline: + raise Leap0TimeoutError("Desktop status stream timed out") + # Non-dict events are heartbeat/info frames; skip them. + if not isinstance(event, dict): + continue + # Explicit error envelope from the server. + if "error" in event: + raise Leap0Error( + "Desktop status stream error", + body=str(event["error"]), + ) yield DesktopProcessStatusList.from_dict(cast(DesktopProcessStatusListDict, event)) finally: response.close() @@ -382,26 +399,33 @@ def wait_until_ready(self, sandbox: SandboxRef, *, timeout: float = 60.0) -> Non Leap0TimeoutError: If the desktop does not become ready within *timeout* seconds. """ - from .common.errors import Leap0TimeoutError def _is_transient_leap0(exc: BaseException) -> bool: - """Return True only for a Leap0Error with a 502 status code.""" - return isinstance(exc, Leap0Error) and exc.status_code == 502 + """Return True for transient Leap0Errors (not timeouts).""" + if isinstance(exc, Leap0TimeoutError): + return False + return isinstance(exc, Leap0Error) + + deadline = time.monotonic() + timeout @retry( stop=stop_after_delay(timeout), wait=wait_exponential(multiplier=0.5, min=0.5, max=5), - retry=retry_if_exception_type((ConnectionError, OSError)) | retry_if_exception(_is_transient_leap0), + retry=retry_if_exception(_is_transient_leap0), reraise=True, ) def _poll() -> None: - for status in self.status_stream(sandbox): + for status in self.status_stream(sandbox, deadline=deadline): if status.status == "running": return raise Leap0Error("Desktop status stream ended without reaching 'running' state") try: _poll() + except Leap0TimeoutError as exc: + raise Leap0TimeoutError( + f"Desktop did not become ready within {timeout:.0f}s: {exc}" + ) from exc except Leap0Error as exc: raise Leap0TimeoutError( f"Desktop did not become ready within {timeout:.0f}s: {exc}"