Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion examples/async_quickstart.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ async def main() -> None:
result: ProcessResult = await sandbox.process.execute(command="echo hello from async leap0")
print("sandbox:", sandbox.id)
print("exit code:", result.exit_code)
print("result:", result.result.strip())
print("stdout:", result.stdout.strip())
print("stderr:", result.stderr.strip())
finally:
await sandbox.delete()

Expand Down
3 changes: 2 additions & 1 deletion examples/quickstart.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ def main() -> None:
result: ProcessResult = sandbox.process.execute(command="echo hello from leap0")
print("sandbox:", sandbox.id)
print("exit code:", result.exit_code)
print("result:", result.result.strip())
print("stdout:", result.stdout.strip())
print("stderr:", result.stderr.strip())
finally:
sandbox.delete()
client.close()
Expand Down
75 changes: 42 additions & 33 deletions leap0/_async/desktop.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
from typing import cast

import httpx
from pydantic import ValidationError

from .._internal.types import JsonObject
from ..models.desktop import (
DesktopClickParams,
DesktopDisplayInfo,
DesktopDisplayInfoDict,
DesktopHealth,
DesktopHealthDict,
DesktopOkResponse,
DesktopPointerPosition,
DesktopPointerPositionDict,
DesktopProcessErrors,
Expand All @@ -29,6 +32,10 @@
DesktopRecordingStatusDict,
DesktopRecordingSummary,
DesktopRecordingSummaryDict,
DesktopResizeScreenParams,
DesktopScreenshotParams,
DesktopScreenshotRegionParams,
DesktopStatusStreamErrorEvent,
DesktopWindow,
DesktopWindowsDict,
)
Expand Down Expand Up @@ -138,7 +145,8 @@ async def resize_screen(self, sandbox: SandboxRef, *, width: int, height: int, h
Returns:
object: Result returned by this operation.
"""
data = cast(DesktopDisplayInfoDict, await self._request_json("POST", sandbox, "/api/display/screen", json={"width": width, "height": height}, http_timeout=http_timeout))
payload = DesktopResizeScreenParams(width=width, height=height).model_dump()
data = cast(DesktopDisplayInfoDict, await self._request_json("POST", sandbox, "/api/display/screen", json=payload, http_timeout=http_timeout))
return DesktopDisplayInfo.from_dict(data)

@intercept_errors("Failed to list windows: ")
Expand Down Expand Up @@ -182,19 +190,14 @@ async def screenshot(
Returns:
object: Result returned by this operation.
"""
params: JsonObject = {}
if image_format is not None:
params["format"] = image_format
if quality is not None:
params["quality"] = quality
if x is not None:
params["x"] = x
if y is not None:
params["y"] = y
if width is not None:
params["width"] = width
if height is not None:
params["height"] = height
params = DesktopScreenshotParams(
format=image_format,
quality=quality,
x=x,
y=y,
width=width,
height=height,
).model_dump(exclude_none=True)
response = await self._request("GET", sandbox, "/api/screenshot", params=params or None, http_timeout=http_timeout)
return response.content

Expand All @@ -220,11 +223,14 @@ async def screenshot_region(
Returns:
object: Result returned by this operation.
"""
payload: JsonObject = {"x": x, "y": y, "width": width, "height": height}
if image_format is not None:
payload["format"] = image_format
if quality is not None:
payload["quality"] = quality
payload = DesktopScreenshotRegionParams(
x=x,
y=y,
width=width,
height=height,
format=image_format,
quality=quality,
).model_dump(exclude_none=True)
response = await self._request("POST", sandbox, "/api/screenshot/region", json=payload, http_timeout=http_timeout)
return response.content

Expand Down Expand Up @@ -269,13 +275,7 @@ async def click(self, sandbox: SandboxRef, *, x: int | None = None, y: int | Non
Returns:
object: Result returned by this operation.
"""
payload: JsonObject = {}
if x is not None:
payload["x"] = x
if y is not None:
payload["y"] = y
if button is not None:
payload["button"] = button
payload = DesktopClickParams(x=x, y=y, button=button).model_dump(exclude_none=True)
data = cast(DesktopPointerPositionDict, await self._request_json("POST", sandbox, "/api/input/click", json=payload, http_timeout=http_timeout))
return DesktopPointerPosition.from_dict(data)

Expand Down Expand Up @@ -328,7 +328,7 @@ async def type_text(self, sandbox: SandboxRef, *, text: str, http_timeout: float
object: Result returned by this operation.
"""
data = await self._request_json("POST", sandbox, "/api/input/type", json={"text": text}, http_timeout=http_timeout)
return bool(data.get("ok", False))
return DesktopOkResponse.model_validate(data).ok

@intercept_errors("Failed to press key: ")
async def press_key(self, sandbox: SandboxRef, *, key: str, http_timeout: float | None = None) -> bool:
Expand All @@ -342,7 +342,7 @@ async def press_key(self, sandbox: SandboxRef, *, key: str, http_timeout: float
object: Result returned by this operation.
"""
data = await self._request_json("POST", sandbox, "/api/input/press", json={"key": key}, http_timeout=http_timeout)
return bool(data.get("ok", False))
return DesktopOkResponse.model_validate(data).ok

@intercept_errors("Failed to press hotkey: ")
async def hotkey(self, sandbox: SandboxRef, *, keys: list[str]) -> bool:
Expand All @@ -356,7 +356,7 @@ async def hotkey(self, sandbox: SandboxRef, *, keys: list[str]) -> bool:
object: Result returned by this operation.
"""
data = await self._request_json("POST", sandbox, "/api/input/hotkey", json={"keys": keys})
return bool(data.get("ok", False))
return DesktopOkResponse.model_validate(data).ok

@intercept_errors("Failed to get recording status: ")
async def recording_status(self, sandbox: SandboxRef, http_timeout: float | None = None) -> DesktopRecordingStatus:
Expand Down Expand Up @@ -567,10 +567,19 @@ async def status_stream(self, sandbox: SandboxRef, *, deadline: float | None = N
except StopAsyncIteration:
break
if not isinstance(event, dict):
raise ValueError(
"Malformed desktop status stream event "
f"for sandbox={sandbox_id_of(sandbox)!r}, source='status_stream': {event!r}"
)
try:
yield DesktopProcessStatusList.from_dict(cast(DesktopProcessStatusListDict, event))
continue
if "error" in event:
raise Leap0Error("Desktop status stream error", body=str(event["error"]))
yield DesktopProcessStatusList.from_dict(cast(DesktopProcessStatusListDict, event))
except (TypeError, ValueError) as status_error:
try:
error_event = DesktopStatusStreamErrorEvent.model_validate(event)
except ValidationError:
raise status_error
raise Leap0Error("Desktop status stream error", body=error_event.detail) from status_error
Comment on lines +574 to +582

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

The structured SSE error branch logic is now correctly ordered.

The refactored flow attempts DesktopStatusStreamErrorEvent.model_validate() before falling back to the generic "error" key check, addressing the previous shadowing concern.

However, per static analysis (Ruff B904), Line 583 should use raise ... from to properly chain exceptions:

Proposed fix for exception chaining
                 except ValidationError:
                     if "error" in event:
                         raise Leap0Error("Desktop status stream error", body=str(event["error"])) from status_error
-                    raise status_error
+                    raise status_error from None
                 raise Leap0Error("Desktop status stream error", body=error_event.detail) from status_error
🧰 Tools
🪛 Ruff (0.15.9)

[warning] 583-583: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@leap0/_async/desktop.py` around lines 574 - 584, The inner except block
catches ValidationError after attempting
DesktopStatusStreamErrorEvent.model_validate(event) but the fallback raise for
the generic "error" key doesn't chain the original status_error; update the
branch inside the ValidationError handler so the generic error path uses "raise
Leap0Error('Desktop status stream error', body=str(event['error'])) from
status_error" (referencing DesktopStatusStreamErrorEvent.model_validate,
DesktopProcessStatusList.from_dict and the local status_error variable) so the
original TypeError/ValueError is chained.

finally:
await response.aclose()

Expand Down Expand Up @@ -598,7 +607,7 @@ async def wait_until_ready(self, sandbox: SandboxRef, *, timeout: float = 60.0,
while time.monotonic() < deadline:
try:
async for status in self.status_stream(sandbox, deadline=deadline, http_timeout=http_timeout):
if status.status == "running":
if status.status == "running" or (status.total > 0 and status.running >= status.total):
return
raise Leap0Error("Desktop status stream ended without reaching 'running' state")
except Leap0TimeoutError as exc:
Expand Down
42 changes: 5 additions & 37 deletions leap0/_async/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
from typing import Any, cast

from .._internal.types import JsonObject
from ..models.filesystem import EditFileResult, EditResult, FileEdit, FileInfo, LsResult, SearchMatch, TreeResult
from ..models.filesystem import EditFileResult, EditResult, FileEdit, FileInfo, LsResult, ReadFileParams, SearchMatch, SetPermissionsParams, TreeResult
from ..models.sandbox import SandboxRef, sandbox_id_of
from .._schemas.filesystem import EditFileResponseDict, EditFilesResponseDict, ExistsResponseDict, FileInfoDict, GlobResponseDict, GrepResponseDict, LsResponseDict, TreeResponseDict
from .._utils.errors import intercept_errors
from .._utils.multipart import parse_multipart_response
from ._transport import AsyncTransport


Expand Down Expand Up @@ -213,17 +214,7 @@ async def read_bytes(
print(content)
```
"""
if head is not None and tail is not None:
raise ValueError("`head` and `tail` are mutually exclusive")
payload: JsonObject = {"path": path}
if offset is not None:
payload["offset"] = offset
if limit is not None:
payload["limit"] = limit
if head is not None:
payload["head"] = head
if tail is not None:
payload["tail"] = tail
payload = ReadFileParams(path=path, offset=offset, limit=limit, head=head, tail=tail).model_dump(exclude_none=True)
response = await self._transport.request("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/read-file", json=payload, timeout=http_timeout)
return response.content

Expand Down Expand Up @@ -347,13 +338,7 @@ async def set_permissions(
Args:
http_timeout: Optional HTTP request timeout in seconds for this SDK call.
"""
payload: JsonObject = {"path": path}
if mode is not None:
payload["mode"] = mode
if owner is not None:
payload["owner"] = owner
if group is not None:
payload["group"] = group
payload = SetPermissionsParams(path=path, mode=mode, owner=owner, group=group).model_dump(exclude_none=True)
await self._transport.request("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/set-permissions", json=payload, expected_status=204, timeout=http_timeout)

@intercept_errors("Failed to glob: ")
Expand Down Expand Up @@ -515,21 +500,4 @@ async def tree(self, sandbox: SandboxRef, *, path: str, max_depth: int | None =


def _parse_multipart_response(content_type: str, body: bytes) -> dict[str, bytes]:
from email.parser import BytesParser

raw = f"Content-Type: {content_type}\r\n\r\n".encode() + body
msg = BytesParser().parsebytes(raw)

result: dict[str, bytes] = {}
if not msg.is_multipart():
raise ValueError(
f"Expected multipart response but got content_type={content_type!r} "
f"(body length={len(body)}, preview='<redacted>')"
)
for part in msg.get_payload(): # type: ignore[union-attr]
name = part.get_param("name", header="content-disposition")
if name:
payload = part.get_payload(decode=True)
if payload is not None:
result[str(name)] = payload
return result
return parse_multipart_response(content_type, body, operation="read_files")
1 change: 1 addition & 0 deletions leap0/_async/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ async def execute(self, sandbox: SandboxRef, *, command: str, cwd: str | None =
command="ls -la /workspace",
)
print(result.stdout)
print(result.stderr)
```
"""
payload: JsonObject = {"command": command}
Expand Down
19 changes: 15 additions & 4 deletions leap0/_async/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,16 @@ async def refresh(self) -> AsyncSandbox:
self._data = latest._data
return self

async def pause(self) -> AsyncSandbox:
async def pause(self, http_timeout: float | None = None) -> AsyncSandbox:
"""Pause the sandbox and return updated metadata.

Args:
http_timeout: Optional HTTP request timeout in seconds for this SDK call.

Returns:
AsyncSandbox: This sandbox object with updated metadata.
"""
latest = await self._client.sandboxes.pause(self)
latest = await self._client.sandboxes.pause(self, http_timeout=http_timeout)
self._data = latest._data
return self

Expand Down Expand Up @@ -237,17 +240,25 @@ async def create(
return self._wrap_sandbox(SandboxData.from_dict(data))

@intercept_errors("Failed to pause sandbox: ")
async def pause(self, sandbox: SandboxRef) -> AsyncSandboxT | SandboxData | SandboxStatus:
async def pause(
self,
sandbox: SandboxRef,
http_timeout: float | None = None,
) -> AsyncSandboxT | SandboxData | SandboxStatus:
"""Pause the sandbox and return updated metadata.

Args:
sandbox: Sandbox ID or object.
http_timeout: Optional HTTP request timeout in seconds for this SDK call.

Returns:
AsyncSandboxT | SandboxData | SandboxStatus: Updated sandbox object.
"""
data: SandboxCreateResponseDict = await self._transport.request_json(
"POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pause", expected_status=201
"POST",
f"/v1/sandbox/{sandbox_id_of(sandbox)}/pause",
expected_status=201,
timeout=http_timeout,
)
return self._wrap_sandbox(SandboxData.from_dict(data))

Expand Down
20 changes: 11 additions & 9 deletions leap0/_schemas/desktop.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from typing import Any, Literal, TypedDict, cast

class DesktopDisplayInfoDict(TypedDict, total=False):
from typing_extensions import NotRequired, Required

class DesktopDisplayInfoDict(TypedDict):
"""Wire schema for desktop display information."""
display: str
width: int
Expand All @@ -26,7 +28,7 @@ class DesktopWindowsDict(TypedDict):
"""Wire schema for desktop window listings."""
items: list[DesktopWindowDict]

class DesktopPointerPositionDict(TypedDict, total=False):
class DesktopPointerPositionDict(TypedDict):
"""Wire schema for pointer position."""
x: int
y: int
Expand All @@ -53,19 +55,19 @@ class DesktopRecordingSummaryDict(TypedDict, total=False):
created_at: str
active: bool

class DesktopHealthDict(TypedDict, total=False):
class DesktopHealthDict(TypedDict):
"""Wire schema for desktop health state."""
ok: bool

class DesktopProcessStatusDict(TypedDict, total=False):
"""Wire schema for one desktop process status."""
name: str
running: bool
pid: int
stdout_log: str
stderr_log: str
name: Required[str]
running: Required[bool]
pid: NotRequired[int]
stdout_log: Required[str]
stderr_log: Required[str]

class DesktopProcessStatusListDict(TypedDict, total=False):
class DesktopProcessStatusListDict(TypedDict):
"""Wire schema for desktop process status listings."""
status: str
items: list[DesktopProcessStatusDict]
Expand Down
4 changes: 3 additions & 1 deletion leap0/_schemas/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@
class ProcessResultDict(TypedDict, total=False):
"""Wire schema for process execution results."""
exit_code: int
result: str
stdout: str
stderr: str
result: object
Loading