From fa88ca67349ce276411599fe0b19de930962e3a8 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Wed, 1 Apr 2026 12:37:34 -0400 Subject: [PATCH 1/6] refactor async --- README.md | 77 +- examples/async_code_interpreter_stream.py | 29 + examples/async_filesystem_and_git.py | 43 ++ examples/async_pty.py | 34 + examples/async_quickstart.py | 28 + examples/code_interpreter_stream.py | 10 +- examples/desktop.py | 25 +- examples/filesystem_and_git.py | 21 +- examples/pty.py | 11 +- examples/quickstart.py | 9 +- examples/snapshots.py | 33 + examples/ssh.py | 30 + leap0/__init__.py | 384 ++++++---- leap0/_async/__init__.py | 30 + leap0/_async/_transport.py | 345 +++++++++ leap0/_async/client.py | 198 ++++++ leap0/_async/code_interpreter.py | 242 +++++++ leap0/_async/desktop.py | 603 ++++++++++++++++ leap0/_async/filesystem.py | 521 ++++++++++++++ leap0/_async/git.py | 442 ++++++++++++ leap0/_async/lsp.py | 246 +++++++ leap0/_async/process.py | 56 ++ leap0/_async/pty.py | 201 ++++++ leap0/_async/sandbox.py | 284 ++++++++ leap0/_async/snapshots.py | 155 +++++ leap0/_async/ssh.py | 92 +++ leap0/_async/templates.py | 75 ++ leap0/{common => _internal}/__init__.py | 0 leap0/_internal/types.py | 34 + leap0/_internal/version.py | 6 + leap0/_schemas/__init__.py | 1 + leap0/_schemas/code_interpreter.py | 53 ++ leap0/_schemas/desktop.py | 88 +++ leap0/_schemas/filesystem.py | 63 ++ leap0/_schemas/git.py | 13 + leap0/_schemas/lsp.py | 20 + leap0/_schemas/process.py | 8 + leap0/_schemas/pty.py | 18 + leap0/_schemas/sandbox.py | 43 ++ leap0/_schemas/snapshot.py | 15 + leap0/_schemas/ssh.py | 18 + leap0/_schemas/template.py | 58 ++ leap0/_sync/__init__.py | 29 + leap0/_sync/_transport.py | 315 +++++++++ leap0/_sync/client.py | 208 ++++++ leap0/_sync/code_interpreter.py | 269 +++++++ leap0/_sync/desktop.py | 655 ++++++++++++++++++ leap0/_sync/filesystem.py | 521 ++++++++++++++ leap0/{ => _sync}/git.py | 287 ++++++-- leap0/{ => _sync}/lsp.py | 162 +++-- leap0/_sync/process.py | 56 ++ leap0/_sync/pty.py | 171 +++++ leap0/_sync/sandbox.py | 301 ++++++++ leap0/_sync/snapshots.py | 155 +++++ leap0/_sync/ssh.py | 92 +++ leap0/_sync/templates.py | 75 ++ leap0/_transport.py | 191 ----- leap0/_utils/encoding.py | 4 + leap0/_utils/errors.py | 156 +++-- leap0/_utils/otel.py | 101 +++ leap0/_utils/stream.py | 26 +- leap0/_utils/url.py | 3 + leap0/client.py | 119 ---- leap0/code_interpreter.py | 170 ----- leap0/common/config.py | 87 --- leap0/common/pty.py | 66 -- leap0/common/snapshot.py | 58 -- leap0/common/template.py | 110 --- leap0/desktop.py | 436 ------------ leap0/filesystem.py | 335 --------- leap0/models/__init__.py | 1 + leap0/{common => models}/code_interpreter.py | 124 ++-- leap0/models/config.py | 78 +++ leap0/{common => models}/desktop.py | 121 +--- leap0/{common => models}/errors.py | 10 - leap0/{common => models}/filesystem.py | 138 ++-- leap0/{common => models}/git.py | 18 +- leap0/{common => models}/lsp.py | 28 +- leap0/{common => models}/process.py | 10 +- leap0/models/pty.py | 90 +++ leap0/{common => models}/sandbox.py | 94 +-- leap0/models/snapshot.py | 108 +++ leap0/{common => models}/ssh.py | 23 +- leap0/models/template.py | 120 ++++ leap0/process.py | 35 - leap0/pty.py | 100 --- leap0/sandboxes.py | 119 ---- leap0/snapshots.py | 107 --- leap0/ssh.py | 49 -- leap0/templates.py | 49 -- pyproject.toml | 4 + tests/_async/__init__.py | 1 + tests/_async/test_client.py | 37 + tests/_async/test_filesystem.py | 33 + tests/_async/test_git.py | 25 + tests/_async/test_process.py | 17 + tests/_async/test_sandboxes.py | 39 ++ tests/_async/test_snapshots.py | 30 + tests/_async/test_ssh.py | 26 + tests/_async/test_templates.py | 29 + tests/_async/test_transport.py | 32 + tests/_sync/__init__.py | 1 + tests/_sync/test_client_config.py | 21 + tests/_sync/test_filesystem.py | 85 +++ tests/{ => _sync}/test_git.py | 2 +- tests/{ => _sync}/test_process.py | 2 +- tests/_sync/test_pty.py | 24 + tests/_sync/test_sandboxes.py | 127 ++++ tests/{ => _sync}/test_snapshots.py | 17 +- tests/{ => _sync}/test_ssh.py | 2 +- tests/{ => _sync}/test_templates.py | 17 +- tests/{ => _sync}/test_transport.py | 10 +- tests/common/__init__.py | 0 tests/conftest.py | 19 +- tests/models/__init__.py | 1 + .../test_code_interpreter.py | 2 +- tests/{common => models}/test_config.py | 4 +- tests/{common => models}/test_desktop.py | 2 +- tests/{common => models}/test_errors.py | 23 +- tests/{common => models}/test_filesystem.py | 2 +- tests/{common => models}/test_git.py | 2 +- tests/{common => models}/test_lsp.py | 2 +- tests/{common => models}/test_process.py | 2 +- tests/{common => models}/test_pty.py | 2 +- tests/{common => models}/test_sandbox.py | 2 +- tests/{common => models}/test_snapshot.py | 2 +- tests/{common => models}/test_ssh.py | 2 +- tests/{common => models}/test_template.py | 2 +- tests/test_docstrings.py | 85 +++ tests/test_filesystem.py | 49 -- tests/test_import.py | 68 +- tests/test_sandboxes.py | 43 -- 132 files changed, 9277 insertions(+), 2860 deletions(-) create mode 100644 examples/async_code_interpreter_stream.py create mode 100644 examples/async_filesystem_and_git.py create mode 100644 examples/async_pty.py create mode 100644 examples/async_quickstart.py create mode 100644 examples/snapshots.py create mode 100644 examples/ssh.py create mode 100644 leap0/_async/__init__.py create mode 100644 leap0/_async/_transport.py create mode 100644 leap0/_async/client.py create mode 100644 leap0/_async/code_interpreter.py create mode 100644 leap0/_async/desktop.py create mode 100644 leap0/_async/filesystem.py create mode 100644 leap0/_async/git.py create mode 100644 leap0/_async/lsp.py create mode 100644 leap0/_async/process.py create mode 100644 leap0/_async/pty.py create mode 100644 leap0/_async/sandbox.py create mode 100644 leap0/_async/snapshots.py create mode 100644 leap0/_async/ssh.py create mode 100644 leap0/_async/templates.py rename leap0/{common => _internal}/__init__.py (100%) create mode 100644 leap0/_internal/types.py create mode 100644 leap0/_internal/version.py create mode 100644 leap0/_schemas/__init__.py create mode 100644 leap0/_schemas/code_interpreter.py create mode 100644 leap0/_schemas/desktop.py create mode 100644 leap0/_schemas/filesystem.py create mode 100644 leap0/_schemas/git.py create mode 100644 leap0/_schemas/lsp.py create mode 100644 leap0/_schemas/process.py create mode 100644 leap0/_schemas/pty.py create mode 100644 leap0/_schemas/sandbox.py create mode 100644 leap0/_schemas/snapshot.py create mode 100644 leap0/_schemas/ssh.py create mode 100644 leap0/_schemas/template.py create mode 100644 leap0/_sync/__init__.py create mode 100644 leap0/_sync/_transport.py create mode 100644 leap0/_sync/client.py create mode 100644 leap0/_sync/code_interpreter.py create mode 100644 leap0/_sync/desktop.py create mode 100644 leap0/_sync/filesystem.py rename leap0/{ => _sync}/git.py (53%) rename leap0/{ => _sync}/lsp.py (50%) create mode 100644 leap0/_sync/process.py create mode 100644 leap0/_sync/pty.py create mode 100644 leap0/_sync/sandbox.py create mode 100644 leap0/_sync/snapshots.py create mode 100644 leap0/_sync/ssh.py create mode 100644 leap0/_sync/templates.py delete mode 100644 leap0/_transport.py create mode 100644 leap0/_utils/otel.py delete mode 100644 leap0/client.py delete mode 100644 leap0/code_interpreter.py delete mode 100644 leap0/common/config.py delete mode 100644 leap0/common/pty.py delete mode 100644 leap0/common/snapshot.py delete mode 100644 leap0/common/template.py delete mode 100644 leap0/desktop.py delete mode 100644 leap0/filesystem.py create mode 100644 leap0/models/__init__.py rename leap0/{common => models}/code_interpreter.py (67%) create mode 100644 leap0/models/config.py rename leap0/{common => models}/desktop.py (78%) rename leap0/{common => models}/errors.py (98%) rename leap0/{common => models}/filesystem.py (54%) rename leap0/{common => models}/git.py (70%) rename leap0/{common => models}/lsp.py (71%) rename leap0/{common => models}/process.py (66%) create mode 100644 leap0/models/pty.py rename leap0/{common => models}/sandbox.py (53%) create mode 100644 leap0/models/snapshot.py rename leap0/{common => models}/ssh.py (76%) create mode 100644 leap0/models/template.py delete mode 100644 leap0/process.py delete mode 100644 leap0/pty.py delete mode 100644 leap0/sandboxes.py delete mode 100644 leap0/snapshots.py delete mode 100644 leap0/ssh.py delete mode 100644 leap0/templates.py create mode 100644 tests/_async/__init__.py create mode 100644 tests/_async/test_client.py create mode 100644 tests/_async/test_filesystem.py create mode 100644 tests/_async/test_git.py create mode 100644 tests/_async/test_process.py create mode 100644 tests/_async/test_sandboxes.py create mode 100644 tests/_async/test_snapshots.py create mode 100644 tests/_async/test_ssh.py create mode 100644 tests/_async/test_templates.py create mode 100644 tests/_async/test_transport.py create mode 100644 tests/_sync/__init__.py create mode 100644 tests/_sync/test_client_config.py create mode 100644 tests/_sync/test_filesystem.py rename tests/{ => _sync}/test_git.py (95%) rename tests/{ => _sync}/test_process.py (91%) create mode 100644 tests/_sync/test_pty.py create mode 100644 tests/_sync/test_sandboxes.py rename tests/{ => _sync}/test_snapshots.py (66%) rename tests/{ => _sync}/test_ssh.py (94%) rename tests/{ => _sync}/test_templates.py (63%) rename tests/{ => _sync}/test_transport.py (91%) delete mode 100644 tests/common/__init__.py create mode 100644 tests/models/__init__.py rename tests/{common => models}/test_code_interpreter.py (99%) rename tests/{common => models}/test_config.py (95%) rename tests/{common => models}/test_desktop.py (98%) rename tests/{common => models}/test_errors.py (67%) rename tests/{common => models}/test_filesystem.py (98%) rename tests/{common => models}/test_git.py (93%) rename tests/{common => models}/test_lsp.py (93%) rename tests/{common => models}/test_process.py (84%) rename tests/{common => models}/test_pty.py (96%) rename tests/{common => models}/test_sandbox.py (96%) rename tests/{common => models}/test_snapshot.py (94%) rename tests/{common => models}/test_ssh.py (92%) rename tests/{common => models}/test_template.py (95%) create mode 100644 tests/test_docstrings.py delete mode 100644 tests/test_filesystem.py delete mode 100644 tests/test_sandboxes.py diff --git a/README.md b/README.md index 606fa46..11fff35 100644 --- a/README.md +++ b/README.md @@ -50,14 +50,13 @@ client = Leap0(Leap0Config()) sandbox = client.sandboxes.create() try: - result = client.code_interpreter.execute( - sandbox, + result = sandbox.code_interpreter.execute( code="sum([10, 20, 30, 40]) / 4", language="python", ) print(result.main_text) # "25.0" finally: - client.sandboxes.delete(sandbox) + sandbox.delete() client.close() ``` @@ -68,11 +67,11 @@ from leap0 import Leap0Client with Leap0Client(api_key="your-api-key") as client: sandbox = client.sandboxes.create() - result = client.code_interpreter.execute( - sandbox, code="print('Hello from the sandbox!')", language="python" + result = sandbox.code_interpreter.execute( + code="print('Hello from the sandbox!')", language="python" ) print(result.main_text) - client.sandboxes.delete(sandbox) + sandbox.delete() ``` ## Features @@ -82,13 +81,11 @@ with Leap0Client(api_key="your-api-key") as client: Stateful REPL sessions for Python and TypeScript. Variables persist across calls, with Matplotlib charts auto-captured as PNG and SVG. ```python -result = client.code_interpreter.execute( - sandbox, code="x = 42", language="python" -) +result = sandbox.code_interpreter.execute(code="x = 42", language="python") # Stream output in real-time -for event in client.code_interpreter.execute_stream( - sandbox, code="for i in range(5): print(i)", language="python" +for event in sandbox.code_interpreter.execute_stream( + code="for i in range(5): print(i)", language="python" ): print(event.type, event.data) ``` @@ -98,10 +95,10 @@ for event in client.code_interpreter.execute_stream( Full filesystem access inside every sandbox. List, read, write, edit, search, and more. ```python -client.filesystem.write_file_text(sandbox, path="/workspace/hello.txt", content="Hello!") -content = client.filesystem.read_file_text(sandbox, path="/workspace/hello.txt") -tree = client.filesystem.tree(sandbox, path="/workspace", max_depth=2) -matches = client.filesystem.grep(sandbox, path="/workspace", pattern="TODO") +sandbox.filesystem.write_file(path="/workspace/hello.txt", content="Hello!") +content = sandbox.filesystem.read_file(path="/workspace/hello.txt") +tree = sandbox.filesystem.tree(path="/workspace", max_depth=2) +matches = sandbox.filesystem.grep(path="/workspace", pattern="TODO") ``` ### Git @@ -109,11 +106,11 @@ matches = client.filesystem.grep(sandbox, path="/workspace", pattern="TODO") Clone repos, create branches, stage files, commit, push, and pull, all inside the sandbox. ```python -client.git.clone(sandbox, url="https://github.com/user/repo.git", path="/workspace/repo") -status = client.git.status(sandbox, path="/workspace/repo") -client.git.add(sandbox, path="/workspace/repo", files=["README.md"]) -client.git.commit(sandbox, path="/workspace/repo", message="Update README") -client.git.push(sandbox, path="/workspace/repo") +sandbox.git.clone(url="https://github.com/user/repo.git", path="/workspace/repo") +status = sandbox.git.status(path="/workspace/repo") +sandbox.git.add(path="/workspace/repo", files=["README.md"]) +sandbox.git.commit(path="/workspace/repo", message="Update README") +sandbox.git.push(path="/workspace/repo") ``` ### Process Execution @@ -121,7 +118,7 @@ client.git.push(sandbox, path="/workspace/repo") Execute one-shot shell commands inside a running sandbox. ```python -result = client.process.execute(sandbox, command=["ls", "-la", "/workspace"]) +result = sandbox.process.execute(command="ls -la /workspace") print(result.stdout) ``` @@ -130,8 +127,8 @@ print(result.stdout) Interactive terminal sessions over WebSocket with persistent state. ```python -session = client.pty.create(sandbox, cols=120, rows=30, cwd="/home/user") -conn = client.pty.connect(sandbox, session.id) +session = sandbox.pty.create(cols=120, rows=30, cwd="/home/user") +conn = sandbox.pty.connect(session.id) conn.send("ls -la\n") print(conn.recv().decode()) conn.close() @@ -142,9 +139,19 @@ conn.close() Start language servers for Python and TypeScript to get completions, symbols, and document lifecycle events. ```python -client.lsp.start(sandbox, language="python") -client.lsp.did_open(sandbox, uri="file:///workspace/main.py", language="python") -completions = client.lsp.completions(sandbox, uri="file:///workspace/main.py", line=10, character=5) +sandbox.lsp.start(language_id="python", path_to_project="/workspace") +sandbox.lsp.did_open( + language_id="python", + path_to_project="/workspace", + uri="file:///workspace/main.py", +) +completions = sandbox.lsp.completions( + language_id="python", + path_to_project="/workspace", + uri="file:///workspace/main.py", + line=10, + character=5, +) ``` ### SSH Access @@ -152,7 +159,7 @@ completions = client.lsp.completions(sandbox, uri="file:///workspace/main.py", l Generate and manage time-bound SSH credentials for direct sandbox access. ```python -ssh = client.ssh.create_access(sandbox) +ssh = sandbox.ssh.create_access() print(ssh.hostname, ssh.port, ssh.username) ``` @@ -161,10 +168,12 @@ print(ssh.hostname, ssh.port, ssh.username) Control a graphical desktop inside the sandbox. Take screenshots, move the pointer, click, type, and record the screen. ```python -sandbox = client.sandboxes.create(template_name="system/desktop:v0.1.0") -client.desktop.move_pointer(sandbox, x=500, y=300) -client.desktop.click(sandbox, button=1) -screenshot = client.desktop.screenshot(sandbox, image_format="png") +from leap0 import DEFAULT_DESKTOP_TEMPLATE_NAME + +sandbox = client.sandboxes.create(template_name=DEFAULT_DESKTOP_TEMPLATE_NAME) +sandbox.desktop.move_pointer(x=500, y=300) +sandbox.desktop.click(button=1) +screenshot = sandbox.desktop.screenshot(image_format="png") ``` ### Snapshots @@ -241,10 +250,16 @@ client = Leap0(Leap0Config( See the [`examples/`](examples/) directory for complete usage examples: - **[quickstart.py](examples/quickstart.py)** - Basic code execution +- **[async_quickstart.py](examples/async_quickstart.py)** - Async client quickstart +- **[async_filesystem_and_git.py](examples/async_filesystem_and_git.py)** - Async file and Git operations +- **[async_code_interpreter_stream.py](examples/async_code_interpreter_stream.py)** - Async streaming code execution output +- **[async_pty.py](examples/async_pty.py)** - Async interactive terminal session - **[code_interpreter_stream.py](examples/code_interpreter_stream.py)** - Streaming code execution output - **[filesystem_and_git.py](examples/filesystem_and_git.py)** - File and Git operations - **[pty.py](examples/pty.py)** - Interactive terminal session - **[desktop.py](examples/desktop.py)** - Desktop GUI automation +- **[snapshots.py](examples/snapshots.py)** - Save and restore sandbox state +- **[ssh.py](examples/ssh.py)** - Generate and validate SSH access ## Development diff --git a/examples/async_code_interpreter_stream.py b/examples/async_code_interpreter_stream.py new file mode 100644 index 0000000..9bdb91e --- /dev/null +++ b/examples/async_code_interpreter_stream.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from leap0 import AsyncLeap0Client, AsyncSandbox, StreamEvent + + +async def main() -> None: + async with AsyncLeap0Client() as client: + sandbox: AsyncSandbox = await client.sandboxes.create() + + try: + async for event in sandbox.code_interpreter.execute_stream( + code="import time\nfor i in range(3):\n print(f'async step {i}')\n time.sleep(1)", + language="python", + timeout_ms=10_000, + ): + typed_event: StreamEvent = event + print(typed_event) + finally: + await sandbox.delete() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/async_filesystem_and_git.py b/examples/async_filesystem_and_git.py new file mode 100644 index 0000000..adced50 --- /dev/null +++ b/examples/async_filesystem_and_git.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from leap0 import AsyncLeap0Client, AsyncSandbox, FileInfo, GitResult, TreeResult + + +async def main() -> None: + async with AsyncLeap0Client() as client: + sandbox: AsyncSandbox = await client.sandboxes.create() + repo_path = "/workspace/hello-world" + + try: + clone: GitResult = await sandbox.git.clone( + url="https://github.com/octocat/Hello-World.git", + path=repo_path, + branch="master", + ) + print("clone exit:", clone.exit_code) + + status: GitResult = await sandbox.git.status(path=repo_path) + print("git status:\n", status.output) + + await sandbox.filesystem.write_file( + path=f"{repo_path}/async-demo.txt", + content="Hello from the async Leap0 Python SDK\n", + ) + + file_info: FileInfo = await sandbox.filesystem.stat(path=f"{repo_path}/README") + print("readme size:", file_info.size) + + tree: TreeResult = await sandbox.filesystem.tree(path=repo_path, max_depth=2) + print("tree items:", [entry.name for entry in tree.items]) + finally: + await sandbox.delete() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/async_pty.py b/examples/async_pty.py new file mode 100644 index 0000000..e0e37a3 --- /dev/null +++ b/examples/async_pty.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from leap0 import AsyncLeap0Client, AsyncPtyConnection, AsyncSandbox, PtySession + + +async def main() -> None: + async with AsyncLeap0Client() as client: + sandbox: AsyncSandbox = await client.sandboxes.create() + + try: + session: PtySession = await sandbox.pty.create( + session_id="async-demo-terminal", + cols=120, + rows=30, + cwd="/home/user", + ) + connection: AsyncPtyConnection = await sandbox.pty.connect(session.id) + try: + await connection.send("pwd\n") + print((await connection.recv()).decode("utf-8", errors="replace")) + finally: + await connection.close() + finally: + await sandbox.delete() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/async_quickstart.py b/examples/async_quickstart.py new file mode 100644 index 0000000..19c5b5d --- /dev/null +++ b/examples/async_quickstart.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from leap0 import AsyncLeap0Client, AsyncSandbox, CodeExecutionResult + + +async def main() -> None: + async with AsyncLeap0Client() as client: + sandbox: AsyncSandbox = await client.sandboxes.create() + + try: + result: CodeExecutionResult = await sandbox.code_interpreter.execute( + code="print('hello from async leap0')", + language="python", + ) + print("sandbox:", sandbox.id) + print("result:", result.main_text) + finally: + await sandbox.delete() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/code_interpreter_stream.py b/examples/code_interpreter_stream.py index 2f13760..d7b38f1 100644 --- a/examples/code_interpreter_stream.py +++ b/examples/code_interpreter_stream.py @@ -4,23 +4,23 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from leap0 import Leap0, Leap0Config +from leap0 import Leap0, Leap0Config, Sandbox, StreamEvent def main() -> None: client = Leap0(Leap0Config()) - sandbox = client.sandboxes.create() + sandbox: Sandbox = client.sandboxes.create() try: - for event in client.code_interpreter.execute_stream( - sandbox, + event: StreamEvent + for event in sandbox.code_interpreter.execute_stream( code="import time\nfor i in range(3):\n print(f'step {i}')\n time.sleep(1)", language="python", timeout_ms=10_000, ): print(event) finally: - client.sandboxes.delete(sandbox) + sandbox.delete() client.close() diff --git a/examples/desktop.py b/examples/desktop.py index 0c9759b..5ce53ad 100644 --- a/examples/desktop.py +++ b/examples/desktop.py @@ -5,29 +5,36 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from leap0 import Leap0, Leap0Config, Leap0Error +from leap0 import ( + DEFAULT_DESKTOP_TEMPLATE_NAME, + DesktopDisplayInfo, + Leap0, + Leap0Config, + Leap0Error, + Sandbox, +) def main() -> None: client = Leap0(Leap0Config()) - sandbox = client.sandboxes.create(template_name="system/desktop:v0.1.0") + sandbox: Sandbox = client.sandboxes.create(template_name=DEFAULT_DESKTOP_TEMPLATE_NAME) try: - client.desktop.wait_until_ready(sandbox, timeout=60.0) - print("Desktop:", client.desktop.desktop_url(sandbox)) + sandbox.desktop.wait_until_ready(timeout=60.0) + print("Desktop:", sandbox.desktop.desktop_url()) - display = client.desktop.display_info(sandbox) + display: DesktopDisplayInfo = sandbox.desktop.display_info() print("Display:", display) - client.desktop.move_pointer(sandbox, x=display.width // 2, y=display.height // 2) - client.desktop.click(sandbox, button=1) + sandbox.desktop.move_pointer(x=display.width // 2, y=display.height // 2) + sandbox.desktop.click(button=1) - screenshot = client.desktop.screenshot(sandbox, image_format="png") + screenshot = sandbox.desktop.screenshot(image_format="png") Path("desktop-screenshot.png").write_bytes(screenshot) print("Saved screenshot to desktop-screenshot.png") finally: try: - client.sandboxes.delete(sandbox) + sandbox.delete() except Leap0Error: pass finally: diff --git a/examples/filesystem_and_git.py b/examples/filesystem_and_git.py index 2734f1e..22496ae 100644 --- a/examples/filesystem_and_git.py +++ b/examples/filesystem_and_git.py @@ -4,37 +4,38 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from leap0 import Leap0, Leap0Config +from leap0 import FileInfo, GitResult, Leap0, Leap0Config, Sandbox, TreeResult def main() -> None: client = Leap0(Leap0Config()) - sandbox = client.sandboxes.create() + sandbox: Sandbox = client.sandboxes.create() repo_path = "/workspace/hello-world" try: - clone = client.git.clone( - sandbox, + clone: GitResult = sandbox.git.clone( url="https://github.com/octocat/Hello-World.git", path=repo_path, branch="master", ) print("clone exit:", clone.exit_code) - status = client.git.status(sandbox, path=repo_path) + status: GitResult = sandbox.git.status(path=repo_path) print("git status:\n", status.output) - client.filesystem.write_file_text( - sandbox, + sandbox.filesystem.write_file( path=f"{repo_path}/sdk-demo.txt", content="Hello from the Leap0 Python SDK\n", ) - print("file exists:", client.filesystem.exists(sandbox, path=f"{repo_path}/sdk-demo.txt")) + print("file exists:", sandbox.filesystem.exists(path=f"{repo_path}/sdk-demo.txt")) - tree = client.filesystem.tree(sandbox, path=repo_path, max_depth=2) + file_info: FileInfo = sandbox.filesystem.stat(path=f"{repo_path}/README") + print("readme size:", file_info.size) + + tree: TreeResult = sandbox.filesystem.tree(path=repo_path, max_depth=2) print("tree items:", [entry.name for entry in tree.items]) finally: - client.sandboxes.delete(sandbox) + sandbox.delete() client.close() diff --git a/examples/pty.py b/examples/pty.py index f71fc88..47feda2 100644 --- a/examples/pty.py +++ b/examples/pty.py @@ -4,29 +4,28 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from leap0 import Leap0, Leap0Config +from leap0 import Leap0, Leap0Config, PtyConnection, PtySession, Sandbox def main() -> None: client = Leap0(Leap0Config()) - sandbox = client.sandboxes.create() + sandbox: Sandbox = client.sandboxes.create() try: - session = client.pty.create( - sandbox, + session: PtySession = sandbox.pty.create( session_id="demo-terminal", cols=120, rows=30, cwd="/home/user", ) - connection = client.pty.connect(sandbox, session.id) + connection: PtyConnection = sandbox.pty.connect(session.id) try: connection.send("pwd\n") print(connection.recv().decode("utf-8", errors="replace")) finally: connection.close() finally: - client.sandboxes.delete(sandbox) + sandbox.delete() client.close() diff --git a/examples/quickstart.py b/examples/quickstart.py index 6a000c5..bb8c854 100644 --- a/examples/quickstart.py +++ b/examples/quickstart.py @@ -4,23 +4,22 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from leap0 import Leap0, Leap0Config +from leap0 import CodeExecutionResult, Leap0, Leap0Config, Sandbox def main() -> None: client = Leap0(Leap0Config()) - sandbox = client.sandboxes.create() + sandbox: Sandbox = client.sandboxes.create() try: - result = client.code_interpreter.execute( - sandbox, + result: CodeExecutionResult = sandbox.code_interpreter.execute( code="sum([10, 20, 30, 40]) / 4", language="python", ) print("sandbox:", sandbox.id) print("result:", result.main_text) finally: - client.sandboxes.delete(sandbox) + sandbox.delete() client.close() diff --git a/examples/snapshots.py b/examples/snapshots.py new file mode 100644 index 0000000..b8810e4 --- /dev/null +++ b/examples/snapshots.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from leap0 import Leap0, Leap0Config, Sandbox, Snapshot + + +def main() -> None: + client = Leap0(Leap0Config()) + sandbox: Sandbox = client.sandboxes.create() + + try: + sandbox.filesystem.write_file(path="/workspace/checkpoint.txt", content="before snapshot\n") + + snapshot: Snapshot = client.snapshots.create(sandbox, name="example-checkpoint") + print("snapshot:", snapshot.snapshot_id) + + restored: Sandbox = client.snapshots.resume(snapshot_name=snapshot.name) + try: + content = restored.filesystem.read_file(path="/workspace/checkpoint.txt") + print("restored file:", content.strip()) + finally: + restored.delete() + finally: + sandbox.delete() + client.close() + + +if __name__ == "__main__": + main() diff --git a/examples/ssh.py b/examples/ssh.py new file mode 100644 index 0000000..566fdf6 --- /dev/null +++ b/examples/ssh.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from leap0 import Leap0, Leap0Config, Sandbox, SshAccess, SshValidation + + +def main() -> None: + client = Leap0(Leap0Config()) + sandbox: Sandbox = client.sandboxes.create() + + try: + access: SshAccess = sandbox.ssh.create_access() + print("ssh command:", access.ssh_command) + + validation: SshValidation = sandbox.ssh.validate_access( + access_id=access.id, + password=access.password, + ) + print("ssh valid:", validation.valid) + finally: + sandbox.delete() + client.close() + + +if __name__ == "__main__": + main() diff --git a/leap0/__init__.py b/leap0/__init__.py index 20b8880..118eee9 100644 --- a/leap0/__init__.py +++ b/leap0/__init__.py @@ -1,109 +1,133 @@ -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, - Leap0Error, - Leap0NotFoundError, - Leap0PermissionError, - Leap0RateLimitError, - Leap0TimeoutError, - Leap0WebSocketError, -) -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 LspJsonRpcError, LspJsonRpcErrorDict, LspJsonRpcResponse, LspJsonRpcResponseDict, 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, -) +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ._async.client import AsyncLeap0, AsyncLeap0Client, AsyncPtyConnection + from ._async.code_interpreter import AsyncCodeInterpreterClient + from ._async.desktop import AsyncDesktopClient + from ._async.filesystem import AsyncFilesystemClient + from ._async.git import AsyncGitClient + from ._async.lsp import AsyncLspClient + from ._async.process import AsyncProcessClient + from ._async.pty import AsyncPtyClient + from ._async.sandbox import AsyncSandbox, AsyncSandboxesClient + from ._async.snapshots import AsyncSnapshotsClient + from ._async.ssh import AsyncSshClient + from ._async.templates import AsyncTemplatesClient + from ._sync.client import Leap0, Leap0Client + from ._sync.code_interpreter import CodeInterpreterClient + from .models.code_interpreter import ( + CodeContext, + CodeExecutionError, + CodeExecutionOutput, + CodeExecutionResult, + ExecutionLogs, + StreamEvent, + ) + from .models.config import ( + DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME, + DEFAULT_DESKTOP_TEMPLATE_NAME, + DEFAULT_TEMPLATE_NAME, + Leap0Config, + ) + from .models.errors import ( + Leap0ConflictError, + Leap0Error, + Leap0NotFoundError, + Leap0PermissionError, + Leap0RateLimitError, + Leap0TimeoutError, + Leap0WebSocketError, + ) + from .models.desktop import ( + DesktopDisplayInfo, + DesktopHealth, + DesktopPointerPosition, + DesktopProcessErrors, + DesktopProcessLogs, + DesktopProcessRestart, + DesktopProcessStatus, + DesktopProcessStatusList, + DesktopRecordingStatus, + DesktopRecordingSummary, + DesktopWindow, + ) + from .models.filesystem import ( + EditFileResult, + EditResult, + FileEdit, + FileInfo, + LsResult, + SearchMatch, + TreeEntry, + TreeResult, + ) + from .models.git import GitCommitResult, GitResult + from .models.lsp import ( + LspJsonRpcError, + LspJsonRpcResponse, + LspResponse, + ) + from .models.process import ProcessResult + from .models.pty import CreatePtySessionParams, PtyConnection, PtySession + from .models.sandbox import ( + CreateSandboxParams, + NetworkPolicyMode, + SandboxState, + SandboxStatus, + sandbox_id_of, + ) + from .models.snapshot import CreateSnapshotParams, ResumeSnapshotParams, Snapshot, snapshot_id_of + from .models.ssh import SshAccess, SshValidation + from .models.template import ( + CreateTemplateParams, + ImageConfig, + RegistryCredentialType, + RegistryCredentialsDict, + RenameTemplateParams, + Template, + ) + from ._sync.desktop import DesktopClient + from ._sync.filesystem import FilesystemClient + from ._sync.git import GitClient + from ._sync.lsp import LspClient + from ._sync.process import ProcessClient + from ._sync.pty import PtyClient + from ._sync.sandbox import Sandbox, SandboxesClient + from ._sync.snapshots import SnapshotsClient + from ._sync.ssh import SshClient + from ._sync.templates import TemplatesClient -# 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__ = [ + "AsyncLeap0", + "AsyncLeap0Client", + "AsyncCodeInterpreterClient", + "AsyncDesktopClient", + "AsyncFilesystemClient", + "AsyncGitClient", + "AsyncLspClient", + "AsyncProcessClient", + "AsyncPtyClient", + "AsyncPtyConnection", + "AsyncSandbox", + "AsyncSandboxesClient", + "AsyncSnapshotsClient", + "AsyncSshClient", + "AsyncTemplatesClient", "CodeContext", "CodeExecutionError", "CodeExecutionOutput", "CodeExecutionResult", - "DesktopDisplayInfo", + "CodeInterpreterClient", + "CreatePtySessionParams", + "CreateSandboxParams", + "CreateSnapshotParams", + "CreateTemplateParams", "DesktopClient", + "DesktopDisplayInfo", "DesktopHealth", "DesktopPointerPosition", "DesktopProcessErrors", @@ -125,15 +149,18 @@ "GitResult", "ImageConfig", "Leap0", - "Leap0ConflictError", - "Leap0Config", "Leap0Client", + "Leap0Config", + "Leap0ConflictError", "Leap0Error", "Leap0NotFoundError", "Leap0PermissionError", "Leap0RateLimitError", "Leap0TimeoutError", "Leap0WebSocketError", + "DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME", + "DEFAULT_DESKTOP_TEMPLATE_NAME", + "DEFAULT_TEMPLATE_NAME", "LsResult", "LspClient", "LspJsonRpcError", @@ -144,61 +171,128 @@ "PtyClient", "PtyConnection", "PtySession", + "RenameTemplateParams", + "RegistryCredentialsDict", + "ResumeSnapshotParams", "Sandbox", - "SandboxesClient", + "SandboxState", "SandboxStatus", + "SandboxesClient", "SearchMatch", "Snapshot", "SnapshotsClient", - "SshClient", "SshAccess", + "SshClient", "SshValidation", "StreamEvent", "Template", "TemplatesClient", "TreeEntry", "TreeResult", - "CodeInterpreterClient", - "CodeContextDict", - "CodeExecutionOutputDict", - "CodeExecutionResultDict", - "DesktopDisplayInfoDict", - "DesktopHealthDict", - "DesktopPointerPositionDict", - "DesktopProcessErrorsDict", - "DesktopProcessLogsDict", - "DesktopProcessRestartDict", - "DesktopProcessStatusDict", - "DesktopProcessStatusListDict", - "DesktopRecordingStatusDict", - "DesktopRecordingSummaryDict", - "DesktopWindowDict", - "EditFileResponseDict", - "EditResultDict", - "ExecutionErrorDict", - "ExecutionLogsDict", - "FileInfoDict", - "GitCommitResponseDict", - "GitResultDict", - "GlobResponseDict", - "GrepResponseDict", - "ImageConfigDict", - "LsResponseDict", - "LspJsonRpcErrorDict", - "LspJsonRpcResponseDict", - "LspSuccessResponseDict", - "ProcessResultDict", - "PtySessionInfoDict", - "RegistryCredentialsDict", - "SandboxCreateResponseDict", - "SandboxState", - "SandboxStatusResponseDict", - "SearchMatchDict", - "SnapshotCreateResponseDict", - "SshAccessValidationDict", - "SshCreateAccessDict", - "StreamEventDict", - "TreeEntryDict", - "TreeResponseDict", - "UploadTemplateResponseDict", ] + + +_DYNAMIC_IMPORTS: dict[str, tuple[str, str]] = { + "AsyncLeap0": ("._async.client", "AsyncLeap0"), + "AsyncLeap0Client": ("._async.client", "AsyncLeap0Client"), + "AsyncCodeInterpreterClient": ("._async.code_interpreter", "AsyncCodeInterpreterClient"), + "AsyncDesktopClient": ("._async.desktop", "AsyncDesktopClient"), + "AsyncFilesystemClient": ("._async.filesystem", "AsyncFilesystemClient"), + "AsyncGitClient": ("._async.git", "AsyncGitClient"), + "AsyncLspClient": ("._async.lsp", "AsyncLspClient"), + "AsyncProcessClient": ("._async.process", "AsyncProcessClient"), + "AsyncPtyClient": ("._async.pty", "AsyncPtyClient"), + "AsyncPtyConnection": ("._async.client", "AsyncPtyConnection"), + "AsyncSandbox": ("._async.sandbox", "AsyncSandbox"), + "AsyncSandboxesClient": ("._async.sandbox", "AsyncSandboxesClient"), + "AsyncSnapshotsClient": ("._async.snapshots", "AsyncSnapshotsClient"), + "AsyncSshClient": ("._async.ssh", "AsyncSshClient"), + "AsyncTemplatesClient": ("._async.templates", "AsyncTemplatesClient"), + "Leap0": ("._sync.client", "Leap0"), + "Leap0Client": ("._sync.client", "Leap0Client"), + "CodeInterpreterClient": ("._sync.code_interpreter", "CodeInterpreterClient"), + "DesktopClient": ("._sync.desktop", "DesktopClient"), + "FilesystemClient": ("._sync.filesystem", "FilesystemClient"), + "GitClient": ("._sync.git", "GitClient"), + "LspClient": ("._sync.lsp", "LspClient"), + "ProcessClient": ("._sync.process", "ProcessClient"), + "PtyClient": ("._sync.pty", "PtyClient"), + "Sandbox": ("._sync.sandbox", "Sandbox"), + "SandboxesClient": ("._sync.sandbox", "SandboxesClient"), + "SnapshotsClient": ("._sync.snapshots", "SnapshotsClient"), + "SshClient": ("._sync.ssh", "SshClient"), + "TemplatesClient": ("._sync.templates", "TemplatesClient"), + "Leap0Config": (".models.config", "Leap0Config"), + "DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME": (".models.config", "DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME"), + "DEFAULT_DESKTOP_TEMPLATE_NAME": (".models.config", "DEFAULT_DESKTOP_TEMPLATE_NAME"), + "DEFAULT_TEMPLATE_NAME": (".models.config", "DEFAULT_TEMPLATE_NAME"), + "Leap0ConflictError": (".models.errors", "Leap0ConflictError"), + "Leap0Error": (".models.errors", "Leap0Error"), + "Leap0NotFoundError": (".models.errors", "Leap0NotFoundError"), + "Leap0PermissionError": (".models.errors", "Leap0PermissionError"), + "Leap0RateLimitError": (".models.errors", "Leap0RateLimitError"), + "Leap0TimeoutError": (".models.errors", "Leap0TimeoutError"), + "Leap0WebSocketError": (".models.errors", "Leap0WebSocketError"), + "CreateSandboxParams": (".models.sandbox", "CreateSandboxParams"), + "SandboxStatus": (".models.sandbox", "SandboxStatus"), + "SandboxState": (".models.sandbox", "SandboxState"), + "CreateSnapshotParams": (".models.snapshot", "CreateSnapshotParams"), + "ResumeSnapshotParams": (".models.snapshot", "ResumeSnapshotParams"), + "Snapshot": (".models.snapshot", "Snapshot"), + "EditFileResult": (".models.filesystem", "EditFileResult"), + "EditResult": (".models.filesystem", "EditResult"), + "FileEdit": (".models.filesystem", "FileEdit"), + "FileInfo": (".models.filesystem", "FileInfo"), + "LsResult": (".models.filesystem", "LsResult"), + "SearchMatch": (".models.filesystem", "SearchMatch"), + "TreeEntry": (".models.filesystem", "TreeEntry"), + "TreeResult": (".models.filesystem", "TreeResult"), + "GitCommitResult": (".models.git", "GitCommitResult"), + "GitResult": (".models.git", "GitResult"), + "ProcessResult": (".models.process", "ProcessResult"), + "CreatePtySessionParams": (".models.pty", "CreatePtySessionParams"), + "PtyConnection": (".models.pty", "PtyConnection"), + "PtySession": (".models.pty", "PtySession"), + "LspJsonRpcError": (".models.lsp", "LspJsonRpcError"), + "LspJsonRpcResponse": (".models.lsp", "LspJsonRpcResponse"), + "LspResponse": (".models.lsp", "LspResponse"), + "SshAccess": (".models.ssh", "SshAccess"), + "SshValidation": (".models.ssh", "SshValidation"), + "CreateTemplateParams": (".models.template", "CreateTemplateParams"), + "ImageConfig": (".models.template", "ImageConfig"), + "Template": (".models.template", "Template"), + "RenameTemplateParams": (".models.template", "RenameTemplateParams"), + "CodeContext": (".models.code_interpreter", "CodeContext"), + "CodeExecutionError": (".models.code_interpreter", "CodeExecutionError"), + "CodeExecutionOutput": (".models.code_interpreter", "CodeExecutionOutput"), + "CodeExecutionResult": (".models.code_interpreter", "CodeExecutionResult"), + "ExecutionLogs": (".models.code_interpreter", "ExecutionLogs"), + "StreamEvent": (".models.code_interpreter", "StreamEvent"), + "DesktopDisplayInfo": (".models.desktop", "DesktopDisplayInfo"), + "DesktopHealth": (".models.desktop", "DesktopHealth"), + "DesktopPointerPosition": (".models.desktop", "DesktopPointerPosition"), + "DesktopProcessErrors": (".models.desktop", "DesktopProcessErrors"), + "DesktopProcessLogs": (".models.desktop", "DesktopProcessLogs"), + "DesktopProcessRestart": (".models.desktop", "DesktopProcessRestart"), + "DesktopProcessStatus": (".models.desktop", "DesktopProcessStatus"), + "DesktopProcessStatusList": (".models.desktop", "DesktopProcessStatusList"), + "DesktopRecordingStatus": (".models.desktop", "DesktopRecordingStatus"), + "DesktopRecordingSummary": (".models.desktop", "DesktopRecordingSummary"), + "DesktopWindow": (".models.desktop", "DesktopWindow"), +} + + +def __getattr__(name: str) -> object: + target = _DYNAMIC_IMPORTS.get(name) + if target is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + module_name, attr_name = target + module = importlib.import_module(module_name, __name__) + value = getattr(module, attr_name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(__all__) diff --git a/leap0/_async/__init__.py b/leap0/_async/__init__.py new file mode 100644 index 0000000..f34514b --- /dev/null +++ b/leap0/_async/__init__.py @@ -0,0 +1,30 @@ +from .client import AsyncLeap0, AsyncLeap0Client, AsyncPtyConnection +from .code_interpreter import AsyncCodeInterpreterClient +from .desktop import AsyncDesktopClient +from .filesystem import AsyncFilesystemClient +from .git import AsyncGitClient +from .lsp import AsyncLspClient +from .process import AsyncProcessClient +from .pty import AsyncPtyClient +from .sandbox import AsyncSandbox, AsyncSandboxesClient +from .snapshots import AsyncSnapshotsClient +from .ssh import AsyncSshClient +from .templates import AsyncTemplatesClient + +__all__ = [ + "AsyncCodeInterpreterClient", + "AsyncDesktopClient", + "AsyncFilesystemClient", + "AsyncGitClient", + "AsyncLeap0", + "AsyncLeap0Client", + "AsyncLspClient", + "AsyncProcessClient", + "AsyncPtyClient", + "AsyncPtyConnection", + "AsyncSandbox", + "AsyncSandboxesClient", + "AsyncSnapshotsClient", + "AsyncSshClient", + "AsyncTemplatesClient", +] diff --git a/leap0/_async/_transport.py b/leap0/_async/_transport.py new file mode 100644 index 0000000..afbd4bb --- /dev/null +++ b/leap0/_async/_transport.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from contextvars import ContextVar +from typing import BinaryIO + +import httpx + +from .._internal.types import BinaryFiles, JsonObject +from .._internal.version import SDK_VERSION +from .._utils.otel import with_instrumentation +from ..models.config import DEFAULT_CLIENT_TIMEOUT +from ..models.errors import raise_api_error + + +class AsyncTransport: + """HTTP transport for asynchronous SDK requests. + + Attributes: + api_key: Public attribute exposed by this object. + base_url: Public attribute exposed by this object. + timeout: Public attribute exposed by this object. + auth_header: Public attribute exposed by this object. + bearer: Public attribute exposed by this object. + """ + _timeout_override: ContextVar[float | None] = ContextVar("leap0_async_timeout_override", default=None) + + def __init__( + self, + *, + api_key: str, + base_url: str, + timeout: float = DEFAULT_CLIENT_TIMEOUT, + auth_header: str = "authorization", + bearer: bool = True, + ): + self.api_key = api_key + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.auth_header = auth_header + self.bearer = bearer + self._client = httpx.AsyncClient(timeout=timeout) + + @property + def auth_value(self) -> str: + """Return the formatted authorization header value. + + Returns: + object: Result returned by this operation. + """ + if self.bearer and not self.api_key.lower().startswith("bearer "): + return f"Bearer {self.api_key}" + return self.api_key + + async def close(self) -> None: + """Close the client and release resources.""" + await self._client.aclose() + + @asynccontextmanager + async def override_timeout(self, timeout: float | None): + """Temporarily override the transport timeout for nested calls. + + Args: + timeout: Operation timeout in seconds. + + Yields: + object: Items yielded by this operation. + """ + if timeout is None: + yield + return + token = self._timeout_override.set(timeout) + try: + yield + finally: + self._timeout_override.reset(token) + + def headers(self, extra: dict[str, str] | None = None) -> dict[str, str]: + """Build request headers for the current transport. + + Args: + extra: Parameter for this operation. + + Returns: + object: Result returned by this operation. + """ + headers = { + self.auth_header: self.auth_value, + "Leap0-Source": "sdk-python-async", + "Leap0-SDK-Version": SDK_VERSION, + "User-Agent": f"leap0-python-async/{SDK_VERSION}", + } + if extra: + headers.update(extra) + return headers + + def _expected(self, expected_status: int | tuple[int, ...]) -> tuple[int, ...]: + return (expected_status,) if isinstance(expected_status, int) else expected_status + + def _target_url(self, target: str) -> str: + if target.startswith("https://") or target.startswith("http://"): + return target + return f"{self.base_url}{target}" + + 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_api_error( + response.status_code, + f"Request failed: {method} {target}", + body=response.text, + headers=dict(response.headers), + ) + return response + + @with_instrumentation("async_transport.target_request") + async def _request( + self, + method: str, + target: str, + *, + params: JsonObject | None = None, + json: JsonObject | None = None, + content: bytes | str | BinaryIO | None = None, + files: BinaryFiles | None = None, + headers: dict[str, str] | None = None, + expected_status: int | tuple[int, ...] = 200, + timeout: float | None = None, + ) -> httpx.Response: + response = await self._client.request( + method, + self._target_url(target), + params=params, + json=json, + content=content, + files=files, + headers=self.headers(headers), + timeout=timeout or self._timeout_override.get() or self.timeout, + ) + return self._check_response(response, method, target, expected_status) + + @with_instrumentation("async_transport.stream") + async def _stream( + self, + method: str, + target: str, + *, + json: JsonObject | None = None, + timeout: float | None = None, + ) -> httpx.Response: + effective = timeout if timeout is not None else (self._timeout_override.get() or self.timeout) + timeout_dict = {"connect": effective, "read": effective, "write": effective, "pool": effective} + request = self._client.build_request( + method, + self._target_url(target), + json=json, + headers=self.headers(), + extensions={"timeout": timeout_dict}, + ) + response = await self._client.send(request, stream=True) + if response.status_code >= 400: + body = (await response.aread()).decode("utf-8", errors="replace") + hdrs = dict(response.headers) + await response.aclose() + raise_api_error(response.status_code, f"Request failed: {method} {target}", body=body, headers=hdrs) + return response + + @with_instrumentation("async_transport.request") + async def request( + self, + method: str, + path: str, + *, + params: JsonObject | None = None, + json: JsonObject | None = None, + content: bytes | str | BinaryIO | None = None, + files: BinaryFiles | None = None, + headers: dict[str, str] | None = None, + expected_status: int | tuple[int, ...] = 200, + timeout: float | None = None, + ) -> httpx.Response: + """Send an HTTP request to a control-plane path. + + Args: + method: Parameter for this operation. + path: Path used by this operation. + params: Parameter for this operation. + json: Parameter for this operation. + content: Parameter for this operation. + files: Parameter for this operation. + headers: Parameter for this operation. + expected_status: Parameter for this operation. + timeout: Operation timeout in seconds. + + Returns: + object: Result returned by this operation. + """ + return await self._request( + method, + f"{self.base_url}{path}", + params=params, + json=json, + content=content, + files=files, + headers=headers, + expected_status=expected_status, + timeout=timeout, + ) + + @with_instrumentation("async_transport.request_json") + async def request_json( + self, + method: str, + path: str, + *, + params: JsonObject | None = None, + json: JsonObject | None = None, + content: bytes | str | BinaryIO | None = None, + files: BinaryFiles | None = None, + headers: dict[str, str] | None = None, + expected_status: int | tuple[int, ...] = 200, + timeout: float | None = None, + ) -> JsonObject: + """Send an HTTP request and parse the JSON response. + + Args: + method: Parameter for this operation. + path: Path used by this operation. + params: Parameter for this operation. + json: Parameter for this operation. + content: Parameter for this operation. + files: Parameter for this operation. + headers: Parameter for this operation. + expected_status: Parameter for this operation. + timeout: Operation timeout in seconds. + + Returns: + object: Result returned by this operation. + """ + response = await self.request( + method, + path, + params=params, + json=json, + content=content, + files=files, + headers=headers, + expected_status=expected_status, + timeout=timeout, + ) + return response.json() + + @with_instrumentation("async_transport.request_target") + async def request_target( + self, + method: str, + target: str, + *, + params: JsonObject | None = None, + json: JsonObject | None = None, + expected_status: int | tuple[int, ...] = 200, + timeout: float | None = None, + ) -> httpx.Response: + """Send an HTTP request to a fully qualified target URL. + + Args: + method: Parameter for this operation. + target: Parameter for this operation. + params: Parameter for this operation. + json: Parameter for this operation. + expected_status: Parameter for this operation. + timeout: Operation timeout in seconds. + + Returns: + object: Result returned by this operation. + """ + return await self._request( + method, + target, + params=params, + json=json, + expected_status=expected_status, + timeout=timeout, + ) + + @with_instrumentation("async_transport.request_target_json") + async def request_target_json( + self, + method: str, + target: str, + *, + params: JsonObject | None = None, + json: JsonObject | None = None, + expected_status: int | tuple[int, ...] = 200, + timeout: float | None = None, + ) -> JsonObject: + """Send an HTTP request to a target URL and parse JSON. + + Args: + method: Parameter for this operation. + target: Parameter for this operation. + params: Parameter for this operation. + json: Parameter for this operation. + expected_status: Parameter for this operation. + timeout: Operation timeout in seconds. + + Returns: + object: Result returned by this operation. + """ + response = await self.request_target( + method, + target, + params=params, + json=json, + expected_status=expected_status, + timeout=timeout, + ) + return response.json() + + async def stream( + self, + method: str, + target: str, + *, + json: JsonObject | None = None, + timeout: float | None = None, + ) -> httpx.Response: + """Open a streaming HTTP response. + + Args: + method: Parameter for this operation. + target: Parameter for this operation. + json: Parameter for this operation. + timeout: Operation timeout in seconds. + + Returns: + object: Result returned by this operation. + """ + return await self._stream(method, target, json=json, timeout=timeout) diff --git a/leap0/_async/client.py b/leap0/_async/client.py new file mode 100644 index 0000000..cc66b7f --- /dev/null +++ b/leap0/_async/client.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from types import TracebackType + +from opentelemetry import metrics, trace +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.semconv.attributes import service_attributes + +from ._transport import AsyncTransport +from .._internal.version import SDK_VERSION +from .._utils.otel import with_instrumentation +from ..models.config import ( + DEFAULT_BASE_URL, + DEFAULT_CLIENT_TIMEOUT, + DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME, + DEFAULT_DESKTOP_TEMPLATE_NAME, + DEFAULT_MEMORY_MIB, + DEFAULT_SANDBOX_DOMAIN, + DEFAULT_TEMPLATE_NAME, + DEFAULT_TIMEOUT_MIN, + DEFAULT_VCPU, + Leap0Config, +) +from .code_interpreter import AsyncCodeInterpreterClient +from .desktop import AsyncDesktopClient +from .filesystem import AsyncFilesystemClient +from .git import AsyncGitClient +from .lsp import AsyncLspClient +from .process import AsyncProcessClient +from .pty import AsyncPtyClient, AsyncPtyConnection +from .sandbox import AsyncSandbox, AsyncSandboxesClient +from .snapshots import AsyncSnapshotsClient +from .ssh import AsyncSshClient +from .templates import AsyncTemplatesClient + + +class AsyncLeap0Client: + """Top-level asynchronous client for the Leap0 API. + + Use this client to create sandboxes and access all async service clients. + + Attributes: + sandboxes: Client for sandbox lifecycle operations. + snapshots: Client for snapshot lifecycle operations. + templates: Client for template management. + filesystem: Client for sandbox filesystem operations. + git: Client for Git operations inside a sandbox. + process: Client for one-shot process execution. + pty: Client for interactive PTY sessions. + lsp: Client for Language Server Protocol operations. + ssh: Client for SSH credential management. + code_interpreter: Client for code execution APIs. + desktop: Client for desktop automation APIs. + """ + DEFAULT_BASE_URL = DEFAULT_BASE_URL + DEFAULT_SANDBOX_DOMAIN = DEFAULT_SANDBOX_DOMAIN + DEFAULT_TEMPLATE_NAME = DEFAULT_TEMPLATE_NAME + DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME = DEFAULT_CODE_INTERPRETER_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 + + _tracer_provider: TracerProvider | None = None + _meter_provider: MeterProvider | None = None + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + sandbox_domain: str | None = None, + timeout: float = DEFAULT_CLIENT_TIMEOUT, + auth_header: str = "authorization", + bearer: bool = True, + otel_enabled: bool | None = None, + ): + config = Leap0Config( + api_key=api_key, + base_url=base_url, + sandbox_domain=sandbox_domain, + timeout=timeout, + auth_header=auth_header, + bearer=bearer, + otel_enabled=otel_enabled, + ) + self._transport = AsyncTransport( + api_key=config.api_key, + base_url=config.base_url, + timeout=config.timeout, + auth_header=config.auth_header, + bearer=config.bearer, + ) + self.sandboxes: AsyncSandboxesClient[AsyncSandbox] = AsyncSandboxesClient( + self._transport, + sandbox_domain=config.sandbox_domain, + sandbox_factory=lambda data: AsyncSandbox(self, data), + ) + self.snapshots: AsyncSnapshotsClient[AsyncSandbox] = AsyncSnapshotsClient( + self._transport, + sandbox_factory=lambda data: AsyncSandbox(self, data), + ) + self.templates = AsyncTemplatesClient(self._transport) + self.filesystem = AsyncFilesystemClient(self._transport) + self.git = AsyncGitClient(self._transport) + self.process = AsyncProcessClient(self._transport) + self.pty = AsyncPtyClient(self._transport) + self.lsp = AsyncLspClient(self._transport) + self.ssh = AsyncSshClient(self._transport) + self.code_interpreter = AsyncCodeInterpreterClient(self._transport, sandbox_domain=config.sandbox_domain) + self.desktop = AsyncDesktopClient(self._transport, sandbox_domain=config.sandbox_domain) + + if config.otel_enabled: + self._init_otel() + + def _init_otel(self) -> None: + resource = Resource.create( + { + service_attributes.SERVICE_NAME: "leap0-python-sdk", + service_attributes.SERVICE_VERSION: SDK_VERSION, + } + ) + self._tracer_provider = TracerProvider(resource=resource) + self._tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + trace.set_tracer_provider(self._tracer_provider) + self._meter_provider = MeterProvider(resource=resource) + metrics.set_meter_provider(self._meter_provider) + + @with_instrumentation("async_client.get_sandbox") + async def get_sandbox(self, sandbox_id: str) -> AsyncSandbox: + """Get a sandbox by ID. + + Args: + sandbox_id: Sandbox identifier. + + Returns: + AsyncSandbox: Sandbox object with bound service clients. + """ + return await self.sandboxes.get(sandbox_id) + + @with_instrumentation("async_client.create_sandbox") + async def create_sandbox(self, **kwargs: object) -> AsyncSandbox: + """Create a sandbox. + + Args: + **kwargs: Keyword arguments forwarded to ``client.sandboxes.create``. + + Returns: + AsyncSandbox: Sandbox object with bound service clients. + """ + return await self.sandboxes.create(**kwargs) + + @with_instrumentation("async_client.close") + async def close(self) -> None: + """Close the client and release resources.""" + await self._transport.close() + if self._tracer_provider is not None: + self._tracer_provider.shutdown() + if self._meter_provider is not None: + self._meter_provider.shutdown() + + async def __aenter__(self) -> AsyncLeap0Client: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.close() + + +def AsyncLeap0(config: Leap0Config) -> AsyncLeap0Client: + """Create an asynchronous Leap0 client from a config object. + + Args: + config: Fully resolved Leap0 client configuration. + + Returns: + AsyncLeap0Client: Configured asynchronous client instance. + """ + return AsyncLeap0Client( + api_key=config.api_key, + base_url=config.base_url, + sandbox_domain=config.sandbox_domain, + timeout=config.timeout, + auth_header=config.auth_header, + bearer=config.bearer, + otel_enabled=config.otel_enabled, + ) + + +__all__ = ["AsyncLeap0", "AsyncLeap0Client", "AsyncPtyConnection"] diff --git a/leap0/_async/code_interpreter.py b/leap0/_async/code_interpreter.py new file mode 100644 index 0000000..803453a --- /dev/null +++ b/leap0/_async/code_interpreter.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any, cast + +import httpx + +from .._internal.types import JsonObject +from ._transport import AsyncTransport +from .._utils.errors import intercept_errors +from .._utils.stream import aiter_sse_events +from .._utils.url import sandbox_base_url +from ..models.code_interpreter import ( + CodeContext, + CodeContextDict, + CodeExecutionResult, + CodeExecutionResultDict, + StreamEvent, + StreamEventDict, +) +from ..models.sandbox import SandboxRef, sandbox_id_of + + +class AsyncCodeInterpreterClient: + """Execute code inside a sandbox with asynchronous APIs. + + Attributes: + None. + """ + def __init__(self, transport: AsyncTransport, *, sandbox_domain: str | None = None): + self._transport = transport + self._sandbox_domain = sandbox_domain.strip("/") if sandbox_domain else None + + async def _request( + self, + method: str, + sandbox: SandboxRef, + path: str, + *, + json: JsonObject | None = None, + expected_status: int | tuple[int, ...] = 200, + http_timeout: float | None = None, + ) -> httpx.Response: + return await self._transport.request_target( + method, + f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", + json=json, + expected_status=expected_status, + timeout=http_timeout, + ) + + async def _request_json( + self, + method: str, + sandbox: SandboxRef, + path: str, + *, + json: JsonObject | None = None, + expected_status: int | tuple[int, ...] = 200, + http_timeout: float | None = None, + ) -> JsonObject: + return await self._transport.request_target_json( + method, + f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", + json=json, + expected_status=expected_status, + timeout=http_timeout, + ) + + @intercept_errors("Failed to check interpreter health: ") + async def health(self, sandbox: SandboxRef) -> bool: + """Check service health. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + data = await self._request_json("GET", sandbox, "/healthz") + return data.get("status") == "ok" + + @intercept_errors("Failed to create execution context: ") + async def create_context(self, sandbox: SandboxRef, *, language: str = "python", cwd: str | None = None, http_timeout: float | None = None) -> CodeContext: + """Create a new execution context. + + Args: + sandbox: Sandbox ID or object. + language: Language runtime (e.g. ``"python"``, ``"typescript"``). + cwd: Working directory (default ``"/home/user"``). + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + CodeContext: Newly created persistent execution context. + + """ + payload: JsonObject = {"language": language} + if cwd is not None: + payload["cwd"] = cwd + data = cast(CodeContextDict, await self._request_json("POST", sandbox, "/contexts", json=payload, expected_status=201, http_timeout=http_timeout)) + return CodeContext.from_dict(data) + + @intercept_errors("Failed to list execution contexts: ") + async def list_contexts(self, sandbox: SandboxRef) -> list[CodeContext]: + """List execution contexts. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + raw = await self._request_json("GET", sandbox, "/contexts") + items = cast(list[CodeContextDict], raw.get("items", [])) + return [CodeContext.from_dict(item) for item in items] + + @intercept_errors("Failed to get execution context: ") + async def get_context(self, sandbox: SandboxRef, context_id: str, http_timeout: float | None = None) -> CodeContext: + """Get a single execution context by ID. + + Returns: + CodeContext: Matching execution context. + + Args: + sandbox: Sandbox ID or object. + context_id: Parameter for this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + data = cast(CodeContextDict, await self._request_json("GET", sandbox, f"/contexts/{context_id}", http_timeout=http_timeout)) + return CodeContext.from_dict(data) + + @intercept_errors("Failed to delete execution context: ") + async def delete_context(self, sandbox: SandboxRef, context_id: str) -> None: + """Delete an execution context. + + Args: + sandbox: Sandbox ID or object. + context_id: Parameter for this operation. + """ + await self._request("DELETE", sandbox, f"/contexts/{context_id}", expected_status=204) + + @intercept_errors("Failed to execute code: ") + async def execute( + self, + sandbox: SandboxRef, + *, + code: str, + language: str = "python", + context_id: str | None = None, + env_vars: dict[str, str] | None = None, + timeout_ms: int | None = None, + http_timeout: float | None = None, + ) -> CodeExecutionResult: + """Execute code and wait for the full result. + + Args: + sandbox: Sandbox ID or object. + code: Source code to execute. + language: Language runtime (default ``"python"``). + context_id: Link to an existing context to share state. + Auto-generated if omitted. + env_vars: Environment variables for the execution. + timeout_ms: Execution timeout in milliseconds (default 30000). + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + sandbox: Sandbox ID or object. + code: Source code to execute. + language: Language runtime (default ``"python"``). + context_id: Link to an existing context to share state. + timeout_ms: Execution timeout in milliseconds (default 30000). + + Yields: + StreamEvent: Streaming stdout, stderr, exit, and error events. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + CodeExecutionResult: Structured execution output, errors, and logs. + + """ + payload: JsonObject = {"code": code, "language": language} + if context_id is not None: + payload["context_id"] = context_id + if env_vars is not None: + payload["env_vars"] = env_vars + if timeout_ms is not None: + payload["timeout_ms"] = timeout_ms + response = await self._request("POST", sandbox, "/execute", json=payload, http_timeout=http_timeout) + data = cast(CodeExecutionResultDict, response.json()) + return CodeExecutionResult.from_dict(data) + + @intercept_errors("Failed to execute code: ") + async def execute_stream( + self, + sandbox: SandboxRef, + *, + code: str, + language: str = "python", + context_id: str | None = None, + timeout_ms: int | None = None, + http_timeout: float | None = None, + ) -> AsyncIterator[StreamEvent]: + """Execute code and stream output events via SSE. + + Yields :class:`StreamEvent` objects with type ``"stdout"``, + ``"stderr"``, ``"exit"``, or ``"error"``. + + Args: + sandbox: Sandbox ID or object. + code: Source code to execute. + language: Language runtime for the operation. + context_id: Parameter for this operation. + timeout_ms: Execution timeout in milliseconds. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Yields: + object: Items yielded by this operation. + """ + payload: JsonObject = {"code": code, "language": language} + if context_id is not None: + payload["context_id"] = context_id + if timeout_ms is not None: + payload["timeout_ms"] = timeout_ms + response = await self._transport.stream( + "POST", + f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}/execute/async", + json=payload, + timeout=http_timeout, + ) + try: + async for event in aiter_sse_events(response.aiter_lines()): + yield StreamEvent.from_dict(cast(StreamEventDict, event)) + finally: + await response.aclose() + + +__all__ = ["AsyncCodeInterpreterClient"] diff --git a/leap0/_async/desktop.py b/leap0/_async/desktop.py new file mode 100644 index 0000000..e26ffd4 --- /dev/null +++ b/leap0/_async/desktop.py @@ -0,0 +1,603 @@ +from __future__ import annotations + +import asyncio +import time +from collections.abc import AsyncIterator +from typing import Any, cast + +import httpx + +from .._internal.types import JsonObject +from ._transport import AsyncTransport +from .._utils.errors import intercept_errors +from .._utils.stream import aiter_sse_events +from .._utils.url import sandbox_base_url +from ..models.errors import Leap0Error, Leap0TimeoutError +from ..models.desktop import ( + DesktopDisplayInfo, + DesktopDisplayInfoDict, + DesktopHealth, + DesktopHealthDict, + DesktopPointerPosition, + DesktopPointerPositionDict, + DesktopProcessErrors, + DesktopProcessErrorsDict, + DesktopProcessLogs, + DesktopProcessLogsDict, + DesktopProcessRestart, + DesktopProcessRestartDict, + DesktopProcessStatus, + DesktopProcessStatusDict, + DesktopProcessStatusList, + DesktopProcessStatusListDict, + DesktopRecordingStatus, + DesktopRecordingStatusDict, + DesktopRecordingSummary, + DesktopRecordingSummaryDict, + DesktopWindow, + DesktopWindowsDict, +) +from ..models.sandbox import SandboxRef, sandbox_id_of + + +class AsyncDesktopClient: + """Control a sandbox desktop through asynchronous APIs. + + Attributes: + None. + """ + def __init__(self, transport: AsyncTransport, *, sandbox_domain: str | None = None): + self._transport = transport + self._sandbox_domain = sandbox_domain.strip("/") if sandbox_domain else None + + async def _request( + self, + method: str, + sandbox: SandboxRef, + path: str, + *, + params: JsonObject | None = None, + json: JsonObject | None = None, + expected_status: int | tuple[int, ...] = 200, + http_timeout: float | None = None, + ) -> httpx.Response: + return await self._transport.request_target( + method, + f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", + params=params, + json=json, + expected_status=expected_status, + timeout=http_timeout, + ) + + async def _request_json( + self, + method: str, + sandbox: SandboxRef, + path: str, + *, + params: JsonObject | None = None, + json: JsonObject | None = None, + expected_status: int | tuple[int, ...] = 200, + http_timeout: float | None = None, + ) -> JsonObject: + return await self._transport.request_target_json( + method, + f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", + params=params, + json=json, + expected_status=expected_status, + timeout=http_timeout, + ) + + def desktop_url(self, sandbox: SandboxRef) -> str: + """Build the browser URL for the desktop viewer. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + return f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}/" + + @intercept_errors("Failed to get display info: ") + async def display_info(self, sandbox: SandboxRef) -> DesktopDisplayInfo: + """Get display information for the desktop. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopDisplayInfoDict, await self._request_json("GET", sandbox, "/api/display")) + return DesktopDisplayInfo.from_dict(data) + + @intercept_errors("Failed to get screen info: ") + async def screen(self, sandbox: SandboxRef) -> DesktopDisplayInfo: + """Get the current desktop screen geometry. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopDisplayInfoDict, await self._request_json("GET", sandbox, "/api/display/screen")) + return DesktopDisplayInfo.from_dict(data) + + @intercept_errors("Failed to resize screen: ") + async def resize_screen(self, sandbox: SandboxRef, *, width: int, height: int, http_timeout: float | None = None) -> DesktopDisplayInfo: + """ + Resize the virtual display (width: 320-7680, height: 320-4320). + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + 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)) + return DesktopDisplayInfo.from_dict(data) + + @intercept_errors("Failed to list windows: ") + async def windows(self, sandbox: SandboxRef) -> list[DesktopWindow]: + """List open desktop windows. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopWindowsDict, await self._request_json("GET", sandbox, "/api/display/windows")) + return [DesktopWindow.from_dict(item) for item in data.get("items", [])] + + @intercept_errors("Failed to take screenshot: ") + async def screenshot( + self, + sandbox: SandboxRef, + *, + image_format: str | None = None, + quality: int | None = None, + x: int | None = None, + y: int | None = None, + width: int | None = None, + height: int | None = None, + http_timeout: float | None = None, + ) -> bytes: + """Take a screenshot and return the image as bytes. + + Args: + sandbox: Sandbox ID or object. + image_format: ``"png"``, ``"jpg"``, or ``"jpeg"`` (default ``"png"``). + quality: JPEG quality (1-100). + x: Left edge of capture region. + y: Top edge of capture region. + width: Region width in pixels. + height: Region height in pixels. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + 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 + response = await self._request("GET", sandbox, "/api/screenshot", params=params or None, http_timeout=http_timeout) + return response.content + + @intercept_errors("Failed to take screenshot: ") + async def screenshot_region( + self, + sandbox: SandboxRef, + *, + x: int, + y: int, + width: int, + height: int, + image_format: str | None = None, + quality: int | None = None, + http_timeout: float | None = None, + ) -> bytes: + """ + Take a screenshot of a specific region and return the image as bytes. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + 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 + response = await self._request("POST", sandbox, "/api/screenshot/region", json=payload, http_timeout=http_timeout) + return response.content + + @intercept_errors("Failed to get pointer position: ") + async def pointer_position(self, sandbox: SandboxRef) -> DesktopPointerPosition: + """Get the current pointer position. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopPointerPositionDict, await self._request_json("GET", sandbox, "/api/input/position")) + return DesktopPointerPosition.from_dict(data) + + @intercept_errors("Failed to move pointer: ") + async def move_pointer(self, sandbox: SandboxRef, *, x: int, y: int, http_timeout: float | None = None) -> DesktopPointerPosition: + """ + Move the mouse pointer to the given coordinates. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopPointerPositionDict, await self._request_json("POST", sandbox, "/api/input/move", json={"x": x, "y": y}, http_timeout=http_timeout)) + return DesktopPointerPosition.from_dict(data) + + @intercept_errors("Failed to click: ") + async def click(self, sandbox: SandboxRef, *, x: int | None = None, y: int | None = None, button: int | None = None, http_timeout: float | None = None) -> DesktopPointerPosition: + """Click the mouse. Clicks at the current position if coordinates are omitted. + + Args: + sandbox: Sandbox ID or object. + x: X coordinate. + y: Y coordinate. + button: 1=left, 2=middle, 3=right (default 1). + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + 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 + data = cast(DesktopPointerPositionDict, await self._request_json("POST", sandbox, "/api/input/click", json=payload, http_timeout=http_timeout)) + return DesktopPointerPosition.from_dict(data) + + @intercept_errors("Failed to drag: ") + async def drag(self, sandbox: SandboxRef, *, from_x: int, from_y: int, to_x: int, to_y: int, button: int | None = None, http_timeout: float | None = None) -> DesktopPointerPosition: + """ + Drag from one position to another. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"from_x": from_x, "from_y": from_y, "to_x": to_x, "to_y": to_y} + if button is not None: + payload["button"] = button + data = cast(DesktopPointerPositionDict, await self._request_json("POST", sandbox, "/api/input/drag", json=payload, http_timeout=http_timeout)) + return DesktopPointerPosition.from_dict(data) + + @intercept_errors("Failed to scroll: ") + async def scroll(self, sandbox: SandboxRef, *, direction: str, amount: int | None = None, http_timeout: float | None = None) -> DesktopPointerPosition: + """Scroll the mouse wheel. + + Args: + sandbox: Sandbox ID or object. + direction: ``"up"``, ``"down"``, ``"left"``, or ``"right"``. + amount: Number of scroll steps (1-100, default 1). + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"direction": direction} + if amount is not None: + payload["amount"] = amount + data = cast(DesktopPointerPositionDict, await self._request_json("POST", sandbox, "/api/input/scroll", json=payload, http_timeout=http_timeout)) + return DesktopPointerPosition.from_dict(data) + + @intercept_errors("Failed to type text: ") + async def type_text(self, sandbox: SandboxRef, *, text: str) -> bool: + """Type text through the desktop input service. + + Args: + sandbox: Sandbox ID or object. + text: Parameter for this operation. + + Returns: + object: Result returned by this operation. + """ + data = await self._request_json("POST", sandbox, "/api/input/type", json={"text": text}) + return bool(data.get("ok", False)) + + @intercept_errors("Failed to press key: ") + async def press_key(self, sandbox: SandboxRef, *, key: str, http_timeout: float | None = None) -> bool: + """ + Press a single key by X11 keysym name (e.g. ``"Return"``, ``"Escape"``). + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + 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)) + + @intercept_errors("Failed to press hotkey: ") + async def hotkey(self, sandbox: SandboxRef, *, keys: list[str]) -> bool: + """Send a multi-key hotkey combination. + + Args: + sandbox: Sandbox ID or object. + keys: Parameter for this operation. + + Returns: + 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)) + + @intercept_errors("Failed to get recording status: ") + async def recording_status(self, sandbox: SandboxRef, http_timeout: float | None = None) -> DesktopRecordingStatus: + """ + Get the current screen recording status. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopRecordingStatusDict, await self._request_json("GET", sandbox, "/api/recording", http_timeout=http_timeout)) + return DesktopRecordingStatus.from_dict(data) + + @intercept_errors("Failed to start recording: ") + async def start_recording(self, sandbox: SandboxRef) -> DesktopRecordingStatus: + """Start desktop recording. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopRecordingStatusDict, await self._request_json("POST", sandbox, "/api/recording/start", expected_status=201)) + return DesktopRecordingStatus.from_dict(data) + + @intercept_errors("Failed to stop recording: ") + async def stop_recording(self, sandbox: SandboxRef, http_timeout: float | None = None) -> DesktopRecordingStatus: + """ + Stop the active screen recording. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopRecordingStatusDict, await self._request_json("POST", sandbox, "/api/recording/stop", http_timeout=http_timeout)) + return DesktopRecordingStatus.from_dict(data) + + @intercept_errors("Failed to list recordings: ") + async def recordings(self, sandbox: SandboxRef) -> list[DesktopRecordingSummary]: + """List saved desktop recordings. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + raw = await self._request_json("GET", sandbox, "/api/recordings") + items = cast(list[DesktopRecordingSummaryDict], raw.get("items", [])) + return [DesktopRecordingSummary.from_dict(item) for item in items] + + @intercept_errors("Failed to get recording: ") + async def get_recording(self, sandbox: SandboxRef, recording_id: str, http_timeout: float | None = None) -> DesktopRecordingSummary: + """ + Get details for a single recording. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopRecordingSummaryDict, await self._request_json("GET", sandbox, f"/api/recordings/{recording_id}", http_timeout=http_timeout)) + return DesktopRecordingSummary.from_dict(data) + + @intercept_errors("Failed to download recording: ") + async def download_recording(self, sandbox: SandboxRef, recording_id: str) -> bytes: + """Download a desktop recording as bytes. + + Args: + sandbox: Sandbox ID or object. + recording_id: Recording identifier. + + Returns: + object: Result returned by this operation. + """ + response = await self._request("GET", sandbox, f"/api/recordings/{recording_id}/download") + return response.content + + @intercept_errors("Failed to delete recording: ") + async def delete_recording(self, sandbox: SandboxRef, recording_id: str, http_timeout: float | None = None) -> None: + """ + Delete a recording. Cannot delete an active recording. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + """ + await self._request("DELETE", sandbox, f"/api/recordings/{recording_id}", expected_status=204, http_timeout=http_timeout) + + @intercept_errors("Failed to check desktop health: ") + async def health(self, sandbox: SandboxRef) -> DesktopHealth: + """Check service health. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopHealthDict, await self._request_json("GET", sandbox, "/api/healthz", expected_status=(200, 503))) + return DesktopHealth.from_dict(data) + + @intercept_errors("Failed to get process status: ") + async def process_status(self, sandbox: SandboxRef, http_timeout: float | None = None) -> DesktopProcessStatusList: + """ + Get the status of all desktop processes (xvfb, xfce4, x11vnc, novnc). + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopProcessStatusListDict, await self._request_json("GET", sandbox, "/api/status", http_timeout=http_timeout)) + return DesktopProcessStatusList.from_dict(data) + + @intercept_errors("Failed to get process: ") + async def get_process(self, sandbox: SandboxRef, process_name: str) -> DesktopProcessStatus: + """Get status for one desktop process. + + Args: + sandbox: Sandbox ID or object. + process_name: Desktop process name. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopProcessStatusDict, await self._request_json("GET", sandbox, f"/api/process/{process_name}/status")) + return DesktopProcessStatus.from_dict(data) + + @intercept_errors("Failed to restart process: ") + async def restart_process(self, sandbox: SandboxRef, process_name: str, http_timeout: float | None = None) -> DesktopProcessRestart: + """ + Restart a desktop process. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopProcessRestartDict, await self._request_json("POST", sandbox, f"/api/process/{process_name}/restart", http_timeout=http_timeout)) + return DesktopProcessRestart.from_dict(data) + + @intercept_errors("Failed to get process logs: ") + async def process_logs(self, sandbox: SandboxRef, process_name: str) -> DesktopProcessLogs: + """Get logs for one desktop process. + + Args: + sandbox: Sandbox ID or object. + process_name: Desktop process name. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopProcessLogsDict, await self._request_json("GET", sandbox, f"/api/process/{process_name}/logs")) + return DesktopProcessLogs.from_dict(data) + + @intercept_errors("Failed to get process errors: ") + async def process_errors(self, sandbox: SandboxRef, process_name: str, http_timeout: float | None = None) -> DesktopProcessErrors: + """ + Get stderr logs for a desktop process. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopProcessErrorsDict, await self._request_json("GET", sandbox, f"/api/process/{process_name}/errors", http_timeout=http_timeout)) + return DesktopProcessErrors.from_dict(data) + + @intercept_errors("Failed to stream status: ") + async def status_stream(self, sandbox: SandboxRef, *, deadline: float | None = None, http_timeout: float | None = None) -> AsyncIterator[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. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Yields: + object: Items yielded by this operation. + """ + url = f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}/api/status/stream" + response = await self._transport.stream("GET", url, timeout=http_timeout) + try: + async for event in aiter_sse_events(response.aiter_lines()): + if deadline is not None and time.monotonic() >= deadline: + raise Leap0TimeoutError("Desktop status stream timed out") + if not isinstance(event, dict): + continue + if "error" in event: + raise Leap0Error("Desktop status stream error", body=str(event["error"])) + yield DesktopProcessStatusList.from_dict(cast(DesktopProcessStatusListDict, event)) + finally: + await response.aclose() + + async def wait_until_ready(self, sandbox: SandboxRef, *, timeout: float = 60.0, http_timeout: float | None = None) -> 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). + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Raises: + Leap0TimeoutError: If the desktop does not become ready within + *timeout* seconds. + + """ + deadline = time.monotonic() + timeout + delay = 0.5 + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + async for status in self.status_stream(sandbox, deadline=deadline): + if status.status == "running": + return + raise Leap0Error("Desktop status stream ended without reaching 'running' state") + except Leap0TimeoutError as exc: + raise Leap0TimeoutError(f"Desktop did not become ready within {timeout:.0f}s: {exc}") from exc + except Leap0Error as exc: + last_error = exc + await asyncio.sleep(delay) + delay = min(delay * 2, 5.0) + if last_error is not None: + raise Leap0TimeoutError(f"Desktop did not become ready within {timeout:.0f}s: {last_error}") from last_error + raise Leap0TimeoutError(f"Desktop did not become ready within {timeout:.0f}s") + + +__all__ = ["AsyncDesktopClient"] diff --git a/leap0/_async/filesystem.py b/leap0/_async/filesystem.py new file mode 100644 index 0000000..35e80f7 --- /dev/null +++ b/leap0/_async/filesystem.py @@ -0,0 +1,521 @@ +from __future__ import annotations + +from typing import Any, cast + +from .._internal.types import JsonObject +from ._transport import AsyncTransport +from .._utils.errors import intercept_errors +from ..models.filesystem import EditFileResult, EditResult, FileEdit, FileInfo, LsResult, SearchMatch, TreeResult +from .._schemas.filesystem import EditFileResponseDict, EditFilesResponseDict, ExistsResponseDict, FileInfoDict, GlobResponseDict, GrepResponseDict, LsResponseDict, TreeResponseDict +from ..models.sandbox import SandboxRef, sandbox_id_of + + +class AsyncFilesystemClient: + """List, read, write, move, copy, delete, and search files inside a sandbox. + + Text helpers such as :meth:`write_file` and :meth:`read_file` provide the + most ergonomic default API. Use the ``*_bytes`` variants when you need raw + binary access or want to avoid text decoding. + + Attributes: + None. + """ + + def __init__(self, transport: AsyncTransport): + self._transport = transport + + @intercept_errors("Failed to list directory: ") + async def ls(self, sandbox: SandboxRef, *, path: str, recursive: bool = False, exclude: list[str] | None = None) -> LsResult: + """List directory entries. + + Args: + sandbox: Sandbox ID or object. + path: Directory path to list. + recursive: List recursively. + exclude: Glob patterns to exclude. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"path": path, "recursive": recursive} + if exclude is not None: + payload["exclude"] = exclude + data = cast(LsResponseDict, await self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/ls", json=payload)) + return LsResult.from_dict(data) + + @intercept_errors("Failed to stat file: ") + async def stat(self, sandbox: SandboxRef, *, path: str) -> FileInfo: + """Get metadata for a single path. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + + Returns: + object: Result returned by this operation. + """ + data = cast(FileInfoDict, await self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/stat", json={"path": path})) + return FileInfo.from_dict(data) + + @intercept_errors("Failed to create directory: ") + async def mkdir(self, sandbox: SandboxRef, *, path: str, recursive: bool = False, permissions: str | None = None, http_timeout: float | None = None) -> None: + """Create a directory. Set *recursive* to create parent directories. + + Args: + sandbox: Sandbox ID or object. + path: Directory path to create. + recursive: Create parents as needed. + permissions: Octal permission string (e.g. ``"755"``). + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + payload: JsonObject = {"path": path, "recursive": recursive} + if permissions is not None: + payload["permissions"] = permissions + await self._transport.request( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/mkdir", + json=payload, + expected_status=204, + timeout=http_timeout, + ) + + @intercept_errors("Failed to write file: ") + async def write_bytes(self, sandbox: SandboxRef, *, path: str, content: bytes, permissions: str | None = None, http_timeout: float | None = None) -> None: + """Write raw bytes to a single file path. + + Args: + sandbox: Sandbox ID or object. + path: Destination file path. + content: Raw file bytes. + permissions: Optional octal permission string. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Example: + ```python + client.filesystem.write_bytes( + sandbox, + path="/workspace/logo.png", + content=image_bytes, + ) + ``` + """ + params: dict[str, str] = {"path": path} + if permissions is not None: + params["permissions"] = permissions + await self._transport.request( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/write-file", + params=params, + content=content, + headers={"Content-Type": "application/octet-stream"}, + expected_status=204, + timeout=http_timeout, + ) + + @intercept_errors("Failed to write file: ") + async def write_file(self, sandbox: SandboxRef, *, path: str, content: str, encoding: str = "utf-8", permissions: str | None = None, http_timeout: float | None = None) -> None: + """Write text to a single file path. + + Args: + sandbox: Sandbox ID or object. + path: Destination file path. + content: Text content to write. + encoding: Text encoding used before upload. + permissions: Optional octal permission string. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Example: + ```python + client.filesystem.write_file( + sandbox, + path="/workspace/app.py", + content="print('hello')\n", + ) + ``` + """ + await self.write_bytes(sandbox, path=path, content=content.encode(encoding), permissions=permissions) + + @intercept_errors("Failed to write files: ") + async def write_files_bytes(self, sandbox: SandboxRef, *, files: dict[str, bytes], http_timeout: float | None = None) -> None: + """Write multiple files in a single request. + + Args: + sandbox: Sandbox ID or object. + files: Mapping of file path to raw bytes content. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Example: + ```python + client.filesystem.write_files_bytes( + sandbox, + files={"/workspace/a.bin": b"a", "/workspace/b.bin": b"b"}, + ) + ``` + """ + await self._transport.request( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/write-files", + files=[(path, data) for path, data in files.items()], + expected_status=204, + timeout=http_timeout, + ) + + @intercept_errors("Failed to write files: ") + async def write_files(self, sandbox: SandboxRef, *, files: dict[str, str], encoding: str = "utf-8", http_timeout: float | None = None) -> None: + """Write multiple text files in a single request. + + Args: + sandbox: Sandbox ID or object. + files: Mapping of file path to text content. + encoding: Text encoding used before upload. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + await self.write_files_bytes(sandbox, files={p: c.encode(encoding) for p, c in files.items()}) + + @intercept_errors("Failed to read file: ") + async def read_bytes( + self, + sandbox: SandboxRef, + *, + path: str, + offset: int | None = None, + limit: int | None = None, + head: int | None = None, + tail: int | None = None, + http_timeout: float | None = None, + ) -> bytes: + """Read a single file and return its raw bytes. + + Args: + sandbox: Sandbox ID or object. + path: Path to the file. + offset: Byte offset to start from. + limit: Maximum bytes to read. + head: Return only the first N lines (mutually exclusive with *tail*). + tail: Return only the last N lines (mutually exclusive with *head*). + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + sandbox: Sandbox ID or object. + path: Path to the file. + offset: Byte offset to start from. + limit: Maximum bytes to read. + head: Return only the first N lines. + tail: Return only the last N lines. + encoding: Text encoding used to decode the response body. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Returns: + str: Decoded file contents. + + Example: + ```python + content = sandbox.filesystem.read_file(path="/workspace/README.md") + print(content) + ``` + + Returns: + bytes: File contents as raw bytes. + """ + 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 + response = await self._transport.request("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/read-file", json=payload, timeout=http_timeout) + return response.content + + @intercept_errors("Failed to read file: ") + async def read_file( + self, + sandbox: SandboxRef, + *, + path: str, + offset: int | None = None, + limit: int | None = None, + head: int | None = None, + tail: int | None = None, + encoding: str = "utf-8", + http_timeout: float | None = None, + ) -> str: + """Read a single file and return its content decoded as text. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + offset: Parameter for this operation. + limit: Parameter for this operation. + head: Parameter for this operation. + tail: Parameter for this operation. + encoding: Parameter for this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + return (await self.read_bytes( + sandbox, + path=path, + offset=offset, + limit=limit, + head=head, + tail=tail, + )).decode(encoding) + + @intercept_errors("Failed to read files: ") + async def read_files_bytes(self, sandbox: SandboxRef, *, paths: list[str]) -> dict[str, bytes]: + """Read multiple files and return raw bytes keyed by path. + + Args: + sandbox: Sandbox ID or object. + paths: Parameter for this operation. + + Returns: + object: Result returned by this operation. + """ + response = await 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: ") + async def read_files(self, sandbox: SandboxRef, *, paths: list[str], encoding: str = "utf-8", http_timeout: float | None = None) -> dict[str, str]: + """ + Read multiple files and return decoded text keyed by path. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + return {path: content.decode(encoding) for path, content in (await self.read_files_bytes(sandbox, paths=paths)).items()} + + @intercept_errors("Failed to delete: ") + async def delete(self, sandbox: SandboxRef, *, path: str, recursive: bool = False, http_timeout: float | None = None) -> None: + """Delete a file or directory. + + Args: + sandbox: Sandbox ID or object. + path: Path to delete. + recursive: Required when deleting a non-empty directory. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + await self._transport.request( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/delete", + json={"path": path, "recursive": recursive}, + expected_status=204, + timeout=http_timeout, + ) + + @intercept_errors("Failed to set permissions: ") + async def set_permissions( + self, + sandbox: SandboxRef, + *, + path: str, + mode: str | None = None, + owner: str | None = None, + group: str | None = None, + http_timeout: float | None = None, + ) -> None: + """ + Set file mode and optionally change owner and group. + + 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 + 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: ") + async def glob(self, sandbox: SandboxRef, *, path: str, pattern: str, exclude: list[str] | None = None, http_timeout: float | None = None) -> list[str]: + """Find file paths matching a glob pattern. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + list[str]: Matching file paths. + """ + payload: JsonObject = {"path": path, "pattern": pattern} + if exclude is not None: + payload["exclude"] = exclude + data = cast(GlobResponseDict, await self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/glob", json=payload, timeout=http_timeout)) + return list(data.get("items", [])) + + @intercept_errors("Failed to grep: ") + async def grep(self, sandbox: SandboxRef, *, path: str, pattern: str, include: str | None = None, exclude: list[str] | None = None, http_timeout: float | None = None) -> list[SearchMatch]: + """Search for a text pattern across files in a directory. + + Args: + sandbox: Sandbox ID or object. + path: Base directory to search from. + pattern: Text pattern to search for. + include: File pattern filter (e.g. ``"*.py"``). + exclude: Glob patterns to exclude. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + list[SearchMatch]: Matching lines with file and line metadata. + """ + payload: JsonObject = {"path": path, "pattern": pattern} + if include is not None: + payload["include"] = include + if exclude is not None: + payload["exclude"] = exclude + data = cast(GrepResponseDict, await self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/grep", json=payload, timeout=http_timeout)) + return [SearchMatch.from_dict(item) for item in data.get("items", [])] + + @intercept_errors("Failed to edit file: ") + async def edit_file(self, sandbox: SandboxRef, *, path: str, edits: list[FileEdit], http_timeout: float | None = None) -> EditFileResult: + """Apply one or more find-and-replace edits to a single file. + + Returns: + EditFileResult: Unified diff and replacement count. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + edits: Parameter for this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + data = cast(EditFileResponseDict, await self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/edit-file", + json={"path": path, "edits": [e.to_dict() for e in edits]}, + timeout=http_timeout, + )) + return EditFileResult.from_dict(data) + + @intercept_errors("Failed to edit files: ") + async def edit_files(self, sandbox: SandboxRef, *, paths: list[str], find: str, replace: str = "", http_timeout: float | None = None) -> list[EditResult]: + """Replace text across multiple files at once. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + list[EditResult]: Per-file edit results. + """ + data = cast(EditFilesResponseDict, await self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/edit-files", + json={"files": paths, "find": find, "replace": replace}, + timeout=http_timeout, + )) + return [EditResult.from_dict(item) for item in data.get("items", [])] + + @intercept_errors("Failed to move: ") + async def move(self, sandbox: SandboxRef, *, src_path: str, dst_path: str, overwrite: bool = False) -> None: + """Move or rename a file or directory. + + Args: + sandbox: Sandbox ID or object. + src_path: Parameter for this operation. + dst_path: Parameter for this operation. + overwrite: Parameter for this operation. + """ + await self._transport.request( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/move", + json={"src_path": src_path, "dst_path": dst_path, "overwrite": overwrite}, + expected_status=204, + ) + + @intercept_errors("Failed to copy: ") + async def copy( + self, + sandbox: SandboxRef, + *, + src_path: str, + dst_path: str, + recursive: bool = False, + overwrite: bool | None = None, + http_timeout: float | None = None, + ) -> None: + """Copy a file or directory. + + Args: + sandbox: Sandbox ID or object. + src_path: Source path. + dst_path: Destination path. + recursive: Required when copying a directory. + overwrite: Overwrite the destination if it already exists. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + payload: JsonObject = {"src_path": src_path, "dst_path": dst_path, "recursive": recursive} + if overwrite is not None: + payload["overwrite"] = overwrite + await self._transport.request("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/copy", json=payload, expected_status=204, timeout=http_timeout) + + @intercept_errors("Failed to check path: ") + async def exists(self, sandbox: SandboxRef, *, path: str, http_timeout: float | None = None) -> bool: + """Check whether a path exists in the sandbox. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + bool: ``True`` when the path exists. + """ + data = cast(ExistsResponseDict, await self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/exists", json={"path": path}, timeout=http_timeout)) + return bool(data.get("exists", False)) + + @intercept_errors("Failed to get directory tree: ") + async def tree(self, sandbox: SandboxRef, *, path: str, max_depth: int | None = None, exclude: list[str] | None = None, http_timeout: float | None = None) -> TreeResult: + """Get a recursive directory tree. + + Args: + sandbox: Sandbox ID or object. + path: Root directory path. + max_depth: Maximum traversal depth. + exclude: Glob patterns to exclude. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Returns: + TreeResult: Recursive directory tree rooted at ``path``. + """ + payload: JsonObject = {"path": path} + if max_depth is not None: + payload["max_depth"] = max_depth + if exclude is not None: + payload["exclude"] = exclude + data = cast(TreeResponseDict, await self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/tree", json=payload, timeout=http_timeout)) + return TreeResult.from_dict(data) + + +async 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(): + body_preview = body[:200] if len(body) > 200 else body + raise ValueError( + f"Expected multipart response but got content_type={content_type!r} " + f"(body length={len(body)}, preview={body_preview!r})" + ) + 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 diff --git a/leap0/_async/git.py b/leap0/_async/git.py new file mode 100644 index 0000000..8698833 --- /dev/null +++ b/leap0/_async/git.py @@ -0,0 +1,442 @@ +from __future__ import annotations + +from typing import cast + +from .._internal.types import JsonObject +from ._transport import AsyncTransport +from .._utils.errors import intercept_errors +from ..models.git import GitCommitResult, GitResult +from .._schemas.git import GitCommitResponseDict, GitResultDict +from ..models.sandbox import SandboxRef, sandbox_id_of + + +class AsyncGitClient: + """Clone repositories, inspect diffs and history, manage branches, stage + files, commit, push, and pull inside a running sandbox. + + This client is useful when you want to automate Git workflows without + shelling out manually through :class:`ProcessClient`. + + Attributes: + None. + """ + + def __init__(self, transport: AsyncTransport): + self._transport = transport + + async def _git_result(self, path: str, payload: JsonObject, http_timeout: float | None = None) -> GitResult: + data = cast(GitResultDict, await self._transport.request_json("POST", path, json=payload, timeout=http_timeout)) + return GitResult.from_dict(data) + + @intercept_errors("Failed to clone repository: ") + async def clone( + self, + sandbox: SandboxRef, + *, + url: str, + path: str, + branch: str | None = None, + commit_id: str | None = None, + depth: int | None = None, + username: str | None = None, + password: str | None = None, + http_timeout: float | None = None, + ) -> GitResult: + """Clone a remote repository into the sandbox. + + Args: + sandbox: Sandbox ID or object. + url: Repository URL. + path: Destination path inside the sandbox. + branch: Branch to clone. + commit_id: Specific commit to checkout after cloning. + depth: Shallow clone depth. + username: Auth username (for private repos). + password: Auth password or token (for private repos). + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + GitResult: Command output and exit status from the clone operation. + """ + payload: JsonObject = {"url": url, "path": path} + if branch is not None: + payload["branch"] = branch + if commit_id is not None: + payload["commit_id"] = commit_id + if depth is not None: + payload["depth"] = depth + if username is not None: + payload["username"] = username + if password is not None: + payload["password"] = password + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/clone", payload, http_timeout=http_timeout) + + @intercept_errors("Failed to get git status: ") + async def status(self, sandbox: SandboxRef, *, path: str, http_timeout: float | None = None) -> GitResult: + """Get the current repository status. + + Returns: + GitResult: Git status output and exit status. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/status", {"path": path}, http_timeout=http_timeout) + + @intercept_errors("Failed to list branches: ") + async def branches( + self, + sandbox: SandboxRef, + *, + path: str, + branch_type: str = "local", + contains: str | None = None, + not_contains: str | None = None, + http_timeout: float | None = None, + ) -> GitResult: + """List branches in the repository. + + Args: + sandbox: Sandbox ID or object. + path: Path to the git repo. + branch_type: Filter by ``"local"``, ``"remote"``, or ``"all"``. + contains: Only branches containing this commit SHA. + not_contains: Exclude branches containing this commit SHA. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"path": path, "branch_type": branch_type} + if contains is not None: + payload["contains"] = contains + if not_contains is not None: + payload["not_contains"] = not_contains + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/branches", payload, http_timeout=http_timeout) + + @intercept_errors("Failed to get unstaged diff: ") + async def diff_unstaged(self, sandbox: SandboxRef, *, path: str, context_lines: int | None = None, http_timeout: float | None = None) -> GitResult: + """ + Show working tree changes that are not staged yet. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"path": path} + if context_lines is not None: + payload["context_lines"] = context_lines + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff-unstaged", payload) + + @intercept_errors("Failed to get staged diff: ") + async def diff_staged(self, sandbox: SandboxRef, *, path: str, context_lines: int | None = None, http_timeout: float | None = None) -> GitResult: + """ + Show changes that are already staged for the next commit. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"path": path} + if context_lines is not None: + payload["context_lines"] = context_lines + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff-staged", payload) + + @intercept_errors("Failed to get diff: ") + async def diff(self, sandbox: SandboxRef, *, path: str, target: str, context_lines: int | None = None, http_timeout: float | None = None) -> GitResult: + """ + Compare the current state against a branch, tag, or commit. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"path": path, "target": target} + if context_lines is not None: + payload["context_lines"] = context_lines + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff", payload) + + @intercept_errors("Failed to reset: ") + async def reset(self, sandbox: SandboxRef, *, path: str) -> GitResult: + """Unstage all currently staged changes. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + + Returns: + object: Result returned by this operation. + """ + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/reset", {"path": path}) + + @intercept_errors("Failed to get git log: ") + async def log( + self, + sandbox: SandboxRef, + *, + path: str, + max_count: int | None = None, + start_timestamp: str | None = None, + end_timestamp: str | None = None, + http_timeout: float | None = None, + ) -> GitResult: + """Show commit history with optional limits and date filters. + + Args: + sandbox: Sandbox ID or object. + path: Path to the git repo. + max_count: Maximum number of commits to return (default 10). + start_timestamp: Start timestamp filter. + end_timestamp: End timestamp filter. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"path": path} + if max_count is not None: + payload["max_count"] = max_count + if start_timestamp is not None: + payload["start_timestamp"] = start_timestamp + if end_timestamp is not None: + payload["end_timestamp"] = end_timestamp + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/log", payload) + + @intercept_errors("Failed to show revision: ") + async def show(self, sandbox: SandboxRef, *, path: str, revision: str = "HEAD") -> GitResult: + """Show the full output for a commit, branch, or tag revision. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + revision: Parameter for this operation. + + Returns: + object: Result returned by this operation. + """ + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/show", {"path": path, "revision": revision}) + + @intercept_errors("Failed to create branch: ") + async def create_branch( + self, + sandbox: SandboxRef, + *, + path: str, + name: str, + checkout: bool | None = None, + base_branch: str | None = None, + http_timeout: float | None = None, + ) -> GitResult: + """Create a new branch. + + Args: + sandbox: Sandbox ID or object. + path: Path to the git repo. + name: New branch name. + checkout: Switch to the new branch immediately. + base_branch: Branch from a specific revision. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"path": path, "name": name} + if checkout is not None: + payload["checkout"] = checkout + if base_branch is not None: + payload["base_branch"] = base_branch + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/create-branch", payload) + + @intercept_errors("Failed to checkout branch: ") + async def checkout_branch(self, sandbox: SandboxRef, *, path: str, branch: str, create: bool | None = None, http_timeout: float | None = None) -> GitResult: + """ + Switch to an existing branch. Set *create* to create it if it does not exist. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"path": path, "branch": branch} + if create is not None: + payload["create"] = create + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/checkout-branch", payload) + + @intercept_errors("Failed to delete branch: ") + async def delete_branch(self, sandbox: SandboxRef, *, path: str, name: str, force: bool = False) -> GitResult: + """Delete a branch. Set *force* to delete even if unmerged. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + name: Name used by this operation. + force: Parameter for this operation. + + Returns: + object: Result returned by this operation. + """ + return await 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: ") + async def add(self, sandbox: SandboxRef, *, path: str, files: list[str], http_timeout: float | None = None) -> GitResult: + """ + Stage files for the next commit. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/add", {"path": path, "files": files}) + + @intercept_errors("Failed to commit: ") + async def commit( + self, + sandbox: SandboxRef, + *, + path: str, + message: str, + author: str | None = None, + email: str | None = None, + allow_empty: bool | None = None, + http_timeout: float | None = None, + ) -> GitCommitResult: + """Create a commit from staged changes. + + Args: + sandbox: Sandbox ID or object. + path: Path to the git repo. + message: Commit message. + author: Author name. + email: Author email. + allow_empty: Allow creating an empty commit. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + sandbox: Sandbox ID or object. + path: Path to the git repo. + remote: Remote name (default ``"origin"``). + branch: Branch name. + set_upstream: Set upstream tracking. + username: Auth username. + password: Auth password or token. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + GitCommitResult: Commit result including commit ID when successful. + """ + payload: JsonObject = {"path": path, "message": message} + if author is not None: + payload["author"] = author + if email is not None: + payload["email"] = email + if allow_empty is not None: + payload["allow_empty"] = allow_empty + data = cast(GitCommitResponseDict, await self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/commit", + json=payload, + timeout=http_timeout, + )) + return GitCommitResult.from_dict(data) + + @intercept_errors("Failed to push: ") + async def push( + self, + sandbox: SandboxRef, + *, + path: str, + remote: str | None = None, + branch: str | None = None, + set_upstream: bool | None = None, + username: str | None = None, + password: str | None = None, + http_timeout: float | None = None, + ) -> GitResult: + """Push commits to a remote. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + remote: Parameter for this operation. + branch: Parameter for this operation. + set_upstream: Parameter for this operation. + username: Parameter for this operation. + password: Parameter for this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"path": path} + if remote is not None: + payload["remote"] = remote + if branch is not None: + payload["branch"] = branch + if set_upstream is not None: + payload["set_upstream"] = set_upstream + if username is not None: + payload["username"] = username + if password is not None: + payload["password"] = password + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/push", payload) + + @intercept_errors("Failed to pull: ") + async def pull( + self, + sandbox: SandboxRef, + *, + path: str, + 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, + http_timeout: float | None = None, + ) -> GitResult: + """Pull commits from a remote. Set *rebase* to rebase instead of merge. + + Args: + sandbox: Sandbox ID or object. + path: Path to the git repo. + 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. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"path": path} + if remote is not None: + payload["remote"] = remote + if branch is not None: + 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: + payload["password"] = password + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/pull", payload) diff --git a/leap0/_async/lsp.py b/leap0/_async/lsp.py new file mode 100644 index 0000000..9151aa1 --- /dev/null +++ b/leap0/_async/lsp.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from typing import cast + +from .._internal.types import JsonObject +from ._transport import AsyncTransport +from .._utils.errors import intercept_errors +from .._utils.url import file_uri as _file_uri +from ..models.lsp import LspJsonRpcResponse, LspResponse +from .._schemas.lsp import LspJsonRpcResponseDict, LspSuccessResponseDict +from ..models.sandbox import SandboxRef, sandbox_id_of + + +class AsyncLspClient: + """Start and interact with language servers for code intelligence inside + a sandbox. + + Supported languages: Python (pyright) and TypeScript/JavaScript + (typescript-language-server). + + The typical flow is ``start`` -> ``did_open`` -> ``completions`` or + ``document_symbols`` -> ``did_close`` -> ``stop``. + + Attributes: + None. + """ + + def __init__(self, transport: AsyncTransport): + self._transport = transport + + @intercept_errors("Failed to start LSP server: ") + async def start(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str) -> LspResponse: + """Start the LSP server for a language and project. + + Spawns the server process and sends the LSP ``initialize`` handshake + automatically. + + Args: + sandbox: Sandbox ID or object. + language_id: Language identifier (``"python"``, ``"typescript"``, or ``"javascript"``). + path_to_project: Project directory path inside the sandbox. + + Args: + sandbox: Sandbox ID or object. + language_id: Language identifier. + path_to_project: Project directory path. + uri: Document URI (e.g. ``"file:///home/user/project/main.py"``). + text: Full document text. + version: Document version number. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + LspResponse: Server startup result. + """ + data = cast(LspSuccessResponseDict, await 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})) + return LspResponse.from_dict(data) + + @intercept_errors("Failed to stop LSP server: ") + async def stop(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str) -> LspResponse: + """Send ``shutdown`` and ``exit`` to the language server and terminate it. + + Args: + sandbox: Sandbox ID or object. + language_id: Language identifier for the LSP operation. + path_to_project: Project path inside the sandbox. + + Returns: + object: Result returned by this operation. + """ + data = cast(LspSuccessResponseDict, await 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})) + return LspResponse.from_dict(data) + + @intercept_errors("Failed to open document: ") + async def did_open( + self, + sandbox: SandboxRef, + *, + language_id: str, + path_to_project: str, + uri: str, + text: str | None = None, + version: int = 1, + http_timeout: float | None = None, + ) -> None: + """Notify the language server that a document was opened. + + Must be called before requesting completions or symbols. + + Args: + sandbox: Sandbox ID or object. + language_id: Language identifier for the LSP operation. + path_to_project: Project path inside the sandbox. + uri: Document URI. + text: Parameter for this operation. + version: Parameter for this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + payload: JsonObject = { + "language_id": language_id, + "path_to_project": path_to_project, + "uri": uri, + "version": version, + } + if text is not None: + payload["text"] = text + await self._transport.request( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/lsp/did-open", + json=payload, + expected_status=204, + timeout=http_timeout, + ) + + @intercept_errors("Failed to open document: ") + async def did_open_path( + self, + sandbox: SandboxRef, + *, + language_id: str, + path_to_project: str, + path: str, + text: str | None = None, + version: int = 1, + http_timeout: float | None = None, + ) -> None: + """ + Like :meth:`did_open` but accepts a file path instead of a URI. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + await self.did_open(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path), text=text, version=version, http_timeout=http_timeout) + + @intercept_errors("Failed to close document: ") + async 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. + + Args: + sandbox: Sandbox ID or object. + language_id: Language identifier for the LSP operation. + path_to_project: Project path inside the sandbox. + uri: Document URI. + """ + await self._transport.request( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/lsp/did-close", + json={"language_id": language_id, "path_to_project": path_to_project, "uri": uri}, + expected_status=204, + ) + + @intercept_errors("Failed to close document: ") + async def did_close_path(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str, path: str, http_timeout: float | None = None) -> None: + """ + Like :meth:`did_close` but accepts a file path instead of a URI. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + await self.did_close(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path), http_timeout=http_timeout) + + @intercept_errors("Failed to get completions: ") + async def completions( + self, + sandbox: SandboxRef, + *, + language_id: str, + path_to_project: str, + uri: str, + line: int, + character: int, + http_timeout: float | None = None, + ) -> LspJsonRpcResponse: + """Request completions from the language server. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + LspJsonRpcResponse: Raw JSON-RPC response payload. + """ + data = cast(LspJsonRpcResponseDict, await self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/lsp/completions", + json={ + "language_id": language_id, + "path_to_project": path_to_project, + "uri": uri, + "position": {"line": line, "character": character}, + }, + timeout=http_timeout, + )) + return LspJsonRpcResponse.from_dict(data) + + @intercept_errors("Failed to get completions: ") + async def completions_path( + self, + sandbox: SandboxRef, + *, + language_id: str, + path_to_project: str, + path: str, + line: int, + character: int, + http_timeout: float | None = None, + ) -> LspJsonRpcResponse: + """ + Like :meth:`completions` but accepts a file path instead of a URI. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + return await self.completions(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path), line=line, character=character, http_timeout=http_timeout) + + @intercept_errors("Failed to get document symbols: ") + async def document_symbols(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str, uri: str, http_timeout: float | None = None) -> LspJsonRpcResponse: + """Request document symbols from the language server. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + LspJsonRpcResponse: Raw JSON-RPC response payload. + """ + data = cast(LspJsonRpcResponseDict, await self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/lsp/document-symbols", + json={"language_id": language_id, "path_to_project": path_to_project, "uri": uri}, + timeout=http_timeout, + )) + return LspJsonRpcResponse.from_dict(data) + + @intercept_errors("Failed to get document symbols: ") + async def document_symbols_path(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str, path: str, http_timeout: float | None = None) -> LspJsonRpcResponse: + """ + Like :meth:`document_symbols` but accepts a file path instead of a URI. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + return await self.document_symbols(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path), http_timeout=http_timeout) diff --git a/leap0/_async/process.py b/leap0/_async/process.py new file mode 100644 index 0000000..92a9b89 --- /dev/null +++ b/leap0/_async/process.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import Any, cast + +from .._internal.types import JsonObject +from ._transport import AsyncTransport +from .._utils.errors import intercept_errors +from ..models.process import ProcessResult +from .._schemas.process import ProcessResultDict +from ..models.sandbox import SandboxRef, sandbox_id_of + + +class AsyncProcessClient: + """Execute one-shot shell commands inside a running sandbox. + + Use this client for non-interactive command execution where you want the + full result back as a single response. + + Attributes: + None. + """ + + def __init__(self, transport: AsyncTransport): + self._transport = transport + + @intercept_errors("Failed to execute command: ") + async 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. + + The command runs inside ``/bin/sh -c``. + + Args: + sandbox: Sandbox ID or object. + command: Shell command to execute. + cwd: Working directory. + timeout: Timeout in seconds (default 30). + + Returns: + ProcessResult: Command result including exit code, stdout, and stderr. + + Example: + ```python + result = client.process.execute( + sandbox, + command="ls -la /workspace", + ) + print(result.stdout) + ``` + """ + payload: JsonObject = {"command": command} + if cwd is not None: + payload["cwd"] = cwd + if timeout is not None: + payload["timeout"] = timeout + data = cast(ProcessResultDict, await self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/process/execute", json=payload)) + return ProcessResult.from_dict(data) diff --git a/leap0/_async/pty.py b/leap0/_async/pty.py new file mode 100644 index 0000000..306f466 --- /dev/null +++ b/leap0/_async/pty.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from typing import Any, cast + +from websockets.asyncio.client import ClientConnection, connect + +from .._internal.types import JsonObject +from ._transport import AsyncTransport +from .._utils.errors import intercept_errors +from .._utils.url import websocket_url_from_http +from ..models.pty import CreatePtySessionParams, PtySession +from .._schemas.pty import PtyListResponseDict, PtySessionInfoDict +from ..models.sandbox import SandboxRef, sandbox_id_of + + +class AsyncPtyConnection: + """Asynchronous PTY websocket connection wrapper. + + Attributes: + websocket: Public attribute exposed by this object. + """ + def __init__(self, websocket: ClientConnection): + self.websocket = websocket + + async def send(self, data: str | bytes) -> None: + """Send data through the PTY websocket connection. + + Args: + data: Parameter for this operation. + """ + payload = data.encode() if isinstance(data, str) else data + await self.websocket.send(payload) + + async def recv(self) -> bytes: + """Receive data from the PTY websocket connection. + + Returns: + object: Result returned by this operation. + """ + message = await self.websocket.recv() + if isinstance(message, str): + return message.encode() + if isinstance(message, bytes): + return message + raise TypeError(f"Unexpected message type from websocket: {type(message).__name__}") + + async def close(self) -> None: + """Close the client and release resources.""" + await self.websocket.close() + + +class AsyncPtyClient: + """Create and connect to PTY sessions asynchronously. + + Attributes: + None. + """ + def __init__(self, transport: AsyncTransport): + self._transport = transport + + @intercept_errors("Failed to list PTY sessions: ") + async def list(self, sandbox: SandboxRef) -> list[PtySession]: + """List PTY sessions for a sandbox. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + data = cast(PtyListResponseDict, await self._transport.request_json("GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty")) + return [PtySession.from_dict(item) for item in data.get("items", [])] + + @intercept_errors("Failed to create PTY session: ") + async def create( + self, + sandbox: SandboxRef, + *, + session_id: str | None = None, + cols: int | None = None, + rows: int | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + lazy_start: bool | None = None, + http_timeout: float | None = None, + ) -> PtySession: + """Create a terminal session with a shell process. + + Args: + sandbox: Sandbox ID or object. + session_id: Session ID. Auto-generated if omitted. + cols: Terminal columns. + rows: Terminal rows. + cwd: Starting directory. + envs: Environment variables. + lazy_start: Defer shell start until the first WebSocket connection. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + sandbox: Sandbox ID or object. + session_id: PTY session identifier. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + PtySession: Metadata for the created PTY session. + """ + payload = CreatePtySessionParams( + session_id=session_id, + cols=cols, + rows=rows, + cwd=cwd, + envs=envs, + lazy_start=lazy_start, + ).to_payload() + data = cast(PtySessionInfoDict, await self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty", + json=payload, + expected_status=201, + timeout=http_timeout, + )) + return PtySession.from_dict(data) + + @intercept_errors("Failed to get PTY session: ") + async def get(self, sandbox: SandboxRef, session_id: str) -> PtySession: + """Get an object by ID or identifier. + + Args: + sandbox: Sandbox ID or object. + session_id: PTY session identifier. + + Returns: + object: Result returned by this operation. + """ + data = cast(PtySessionInfoDict, await self._transport.request_json("GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}")) + return PtySession.from_dict(data) + + @intercept_errors("Failed to delete PTY session: ") + async def delete(self, sandbox: SandboxRef, session_id: str, http_timeout: float | None = None) -> None: + """Kill the shell process and remove the session. + + Args: + sandbox: Sandbox ID or object. + session_id: PTY session identifier. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + await self._transport.request("DELETE", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}", expected_status=204, timeout=http_timeout) + + @intercept_errors("Failed to resize PTY session: ") + async def resize(self, sandbox: SandboxRef, session_id: str, *, cols: int, rows: int) -> PtySession: + """Resize a PTY session. + + Args: + sandbox: Sandbox ID or object. + session_id: PTY session identifier. + cols: Parameter for this operation. + rows: Parameter for this operation. + + Returns: + object: Result returned by this operation. + """ + data = cast(PtySessionInfoDict, await self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}/resize", + json={"cols": cols, "rows": rows}, + )) + return PtySession.from_dict(data) + + def websocket_url(self, sandbox: SandboxRef, session_id: str) -> str: + """Build a websocket URL for this sandbox. + + Args: + sandbox: Sandbox ID or object. + session_id: PTY session identifier. + + Returns: + object: Result returned by this operation. + """ + 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: ") + async def connect(self, sandbox: SandboxRef, session_id: str, http_timeout: float | None = None, **kwargs: Any) -> AsyncPtyConnection: + """Open a WebSocket connection for interactive terminal I/O. + + Args: + sandbox: Sandbox ID or object. + session_id: PTY session identifier. + http_timeout: Optional WebSocket open timeout in seconds. + **kwargs: Additional keyword arguments passed to ``websockets.asyncio.client.connect``. + + Returns: + AsyncPtyConnection: Open WebSocket-backed PTY connection. + """ + url = self.websocket_url(sandbox, session_id) + if http_timeout is not None and "open_timeout" not in kwargs: + kwargs["open_timeout"] = http_timeout + websocket = await connect(url, additional_headers={self._transport.auth_header: self._transport.auth_value}, **kwargs) + return AsyncPtyConnection(websocket) + + +__all__ = ["AsyncPtyClient", "AsyncPtyConnection"] diff --git a/leap0/_async/sandbox.py b/leap0/_async/sandbox.py new file mode 100644 index 0000000..92016e6 --- /dev/null +++ b/leap0/_async/sandbox.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +import os +from functools import wraps +from typing import Generic, Protocol, TypeVar, cast + +from ._transport import AsyncTransport +from .._internal.types import SandboxFactory +from .._utils.errors import intercept_errors +from .._utils.url import ensure_leading_slash, sandbox_base_url, websocket_url_from_http +from ..models.config import DEFAULT_MEMORY_MIB, DEFAULT_TEMPLATE_NAME, DEFAULT_TIMEOUT_MIN, DEFAULT_VCPU +from ..models.sandbox import CreateSandboxParams, Sandbox as SandboxData, SandboxRef, SandboxStatus, sandbox_id_of +from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict, SandboxStatusResponseDict + +AsyncSandboxT = TypeVar("AsyncSandboxT", SandboxData, SandboxStatus, "AsyncSandbox") + + +_OTEL_ENV_KEYS = ( + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", +) + + +class _AsyncSandboxServiceProxy: + def __init__(self, service: object, sandbox: AsyncSandbox): + self._service = service + self._sandbox = sandbox + + def __getattr__(self, name: str) -> object: + attr = getattr(self._service, name) + if not callable(attr): + return attr + + bound_attr = cast(_AsyncBoundSandboxCallable, attr) + + @wraps(attr) + async def bound(*args: object, **kwargs: object) -> object: + return await bound_attr(self._sandbox, *args, **kwargs) + + return bound + + +class _AsyncBoundSandboxCallable(Protocol): + async def __call__(self, sandbox: object, *args: object, **kwargs: object) -> object: ... + + +class AsyncSandbox: + """Sandbox object with bound asynchronous service clients. + + Attributes: + filesystem: Bound filesystem client. + fs: Alias for ``filesystem``. + git: Bound git client. + process: Bound process client. + pty: Bound PTY client. + lsp: Bound LSP client. + ssh: Bound SSH client. + code_interpreter: Bound code interpreter client. + desktop: Bound desktop client. + """ + def __init__(self, client: object, data: SandboxData | SandboxStatus): + self._client = client + self._data: SandboxData | SandboxStatus = data + self.filesystem = _AsyncSandboxServiceProxy(client.filesystem, self) + self.fs = self.filesystem + self.git = _AsyncSandboxServiceProxy(client.git, self) + self.process = _AsyncSandboxServiceProxy(client.process, self) + self.pty = _AsyncSandboxServiceProxy(client.pty, self) + self.lsp = _AsyncSandboxServiceProxy(client.lsp, self) + self.ssh = _AsyncSandboxServiceProxy(client.ssh, self) + self.code_interpreter = _AsyncSandboxServiceProxy(client.code_interpreter, self) + self.desktop = _AsyncSandboxServiceProxy(client.desktop, self) + + def __getattr__(self, name: str) -> object: + return getattr(self._data, name) + + def __repr__(self) -> str: + state = getattr(self._data, "state", None) + return f"AsyncSandbox(id={self.id!r}, state={state!r})" + + async def refresh(self) -> AsyncSandbox: + """Refresh this sandbox object with the latest metadata. + + Returns: + AsyncSandbox: This sandbox object with refreshed metadata. + """ + latest = await self._client.sandboxes.get(self.id) + self._data = latest._data + return self + + async def pause(self) -> AsyncSandbox: + """Pause the sandbox and return updated metadata. + + Returns: + AsyncSandbox: This sandbox object with updated metadata. + """ + latest = await self._client.sandboxes.pause(self) + self._data = latest._data + return self + + async def delete(self, http_timeout: float | None = None) -> None: + """Terminate and delete a sandbox. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + await self._client.sandboxes.delete(self, http_timeout=http_timeout) + + def invoke_url(self, path: str = "/", *, port: int | None = None) -> str: + """Build an HTTPS URL for this sandbox. + + Args: + path: Request path inside the sandbox application. + port: Port number for the generated URL. + + Returns: + str: Sandbox-scoped HTTPS URL. + """ + return self._client.sandboxes.invoke_url(self, path=path, port=port) + + def websocket_url(self, path: str = "/", *, port: int | None = None) -> str: + """Build a websocket URL for this sandbox. + + Args: + path: Request path inside the sandbox application. + port: Port number for the generated URL. + + Returns: + str: Sandbox-scoped websocket URL. + """ + return self._client.sandboxes.websocket_url(self, path=path, port=port) + + +def _inject_otel_env(env_vars: dict[str, str] | None) -> dict[str, str] | None: + otel = {k: v for k in _OTEL_ENV_KEYS if (v := os.environ.get(k))} + if not otel and not env_vars: + return None + if not otel: + return env_vars + merged = dict(otel) + if env_vars: + merged.update(env_vars) + return merged + + +class AsyncSandboxesClient(Generic[AsyncSandboxT]): + """Create, inspect, pause, and delete sandboxes asynchronously. + + Attributes: + None. + """ + def __init__( + self, + transport: AsyncTransport, + *, + sandbox_domain: str | None = None, + sandbox_factory: SandboxFactory[SandboxData | SandboxStatus, AsyncSandboxT] | None = None, + ): + self._transport = transport + self._sandbox_domain = sandbox_domain.strip("/") if sandbox_domain else None + self._sandbox_factory = sandbox_factory + + def _wrap_sandbox(self, sandbox: SandboxData | SandboxStatus) -> AsyncSandboxT | SandboxData | SandboxStatus: + if self._sandbox_factory is None: + return sandbox + return self._sandbox_factory(sandbox) + + @intercept_errors("Failed to create sandbox: ") + async def create( + self, + *, + template_name: str = DEFAULT_TEMPLATE_NAME, + vcpu: int = DEFAULT_VCPU, + memory_mib: int = DEFAULT_MEMORY_MIB, + timeout_min: int = DEFAULT_TIMEOUT_MIN, + auto_pause: bool = False, + telemetry: bool = False, + env_vars: dict[str, str] | None = None, + network_policy: NetworkPolicyDict | None = None, + http_timeout: float | None = None, + ) -> AsyncSandboxT | SandboxData | SandboxStatus: + """Create a new sandbox from a template. + + Args: + template_name: Name of the template to use. + vcpu: Number of virtual CPUs (1 to 8). + memory_mib: Memory in MiB (512 to 8192, must be even). + timeout_min: Sandbox timeout in minutes (1 to 480). + auto_pause: Whether the sandbox should auto-pause on timeout. + telemetry: Whether OpenTelemetry variables should be injected. + env_vars: Environment variables to set inside the sandbox. + network_policy: Outbound network policy for the sandbox. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + AsyncSandboxT | SandboxData | SandboxStatus: Created sandbox object. + """ + params = CreateSandboxParams( + template_name=template_name, + vcpu=vcpu, + memory_mib=memory_mib, + timeout_min=timeout_min, + auto_pause=auto_pause, + telemetry=telemetry, + env_vars=_inject_otel_env(env_vars) if telemetry else env_vars, + network_policy=network_policy, + ) + payload = params.to_payload() + payload.pop("telemetry", None) + data: SandboxCreateResponseDict = await self._transport.request_json( + "POST", "/v1/sandbox", json=payload, expected_status=201, + timeout=http_timeout, + ) + return self._wrap_sandbox(SandboxData.from_dict(data)) + + @intercept_errors("Failed to pause sandbox: ") + async def pause(self, sandbox: SandboxRef) -> AsyncSandboxT | SandboxData | SandboxStatus: + """Pause the sandbox and return updated metadata. + + Args: + sandbox: Sandbox ID or object. + + 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 + ) + return self._wrap_sandbox(SandboxData.from_dict(data)) + + @intercept_errors("Failed to get sandbox: ") + async def get(self, sandbox: SandboxRef, http_timeout: float | None = None) -> AsyncSandboxT | SandboxData | SandboxStatus: + """Get the latest sandbox metadata. + + Args: + sandbox: Sandbox ID or object. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + AsyncSandboxT | SandboxData | SandboxStatus: Current sandbox object. + """ + data: SandboxStatusResponseDict = await self._transport.request_json( + "GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/", + timeout=http_timeout, + ) + return self._wrap_sandbox(SandboxStatus.from_dict(data)) + + @intercept_errors("Failed to delete sandbox: ") + async def delete(self, sandbox: SandboxRef) -> None: + """Terminate and delete a sandbox. + + Args: + sandbox: Sandbox ID or object. + """ + await self._transport.request("DELETE", f"/v1/sandbox/{sandbox_id_of(sandbox)}/", expected_status=204) + + def invoke_url(self, sandbox: SandboxRef, path: str = "/", *, port: int | None = None) -> str: + """Build an HTTPS URL for this sandbox. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + port: Port number for the generated URL. + + Returns: + object: Result returned by this operation. + """ + return f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain, port=port)}{ensure_leading_slash(path)}" + + def websocket_url(self, sandbox: SandboxRef, path: str = "/", *, port: int | None = None) -> str: + """Build a websocket URL for this sandbox. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + port: Port number for the generated URL. + + Returns: + object: Result returned by this operation. + """ + return websocket_url_from_http(self.invoke_url(sandbox, path, port=port)) + + +__all__ = ["AsyncSandbox", "AsyncSandboxesClient"] diff --git a/leap0/_async/snapshots.py b/leap0/_async/snapshots.py new file mode 100644 index 0000000..12969b8 --- /dev/null +++ b/leap0/_async/snapshots.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from typing import Generic, TypeVar, cast + +from ._transport import AsyncTransport +from .._internal.types import SandboxFactory +from .._utils.errors import intercept_errors +from ..models.sandbox import Sandbox, SandboxRef, sandbox_id_of +from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict +from ..models.snapshot import CreateSnapshotParams, ResumeSnapshotParams, Snapshot, SnapshotRef, snapshot_id_of +from .._schemas.snapshot import SnapshotCreateResponseDict + +AsyncSnapshotSandboxT = TypeVar("AsyncSnapshotSandboxT") + + +class AsyncSnapshotsClient(Generic[AsyncSnapshotSandboxT]): + """Create, resume, and delete sandbox snapshots. + + A snapshot captures the full state of a running sandbox so it can be + restored later. + + Use snapshots when you want a reusable checkpoint of an initialized + sandbox environment. + + Attributes: + None. + """ + + def __init__(self, transport: AsyncTransport, *, sandbox_factory: SandboxFactory[Sandbox, AsyncSnapshotSandboxT] | None = None): + self._transport = transport + self._sandbox_factory = sandbox_factory + + def _wrap_sandbox(self, sandbox: Sandbox) -> AsyncSnapshotSandboxT | Sandbox: + if self._sandbox_factory is None: + return sandbox + return self._sandbox_factory(sandbox) + + @intercept_errors("Failed to create snapshot: ") + async def create( + self, + sandbox: SandboxRef, + *, + name: str | None = None, + http_timeout: float | None = None, + ) -> Snapshot: + """Create a snapshot of a running sandbox without stopping it. + + Args: + sandbox: Sandbox ID or object to snapshot. + name: Optional snapshot name. Auto-generated if omitted. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + sandbox: Sandbox ID or object to pause. + name: Optional snapshot name. Auto-generated if omitted. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Returns: + Snapshot: Created snapshot metadata. + + Returns: + Snapshot: Snapshot metadata including ID and optional name. + """ + payload = CreateSnapshotParams(name=name).to_payload() + data = cast(SnapshotCreateResponseDict, await self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/snapshot/create", + json=payload, + expected_status=201, + timeout=http_timeout, + )) + return Snapshot.from_dict(data) + + @intercept_errors("Failed to pause sandbox: ") + async def pause( + self, + sandbox: SandboxRef, + *, + name: str | None = None, + http_timeout: float | None = None, + ) -> Snapshot: + """Pause a running sandbox and create a snapshot in one step. + + The sandbox is stopped after the snapshot is taken. + + Args: + sandbox: Sandbox ID or object. + name: Name used by this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload = CreateSnapshotParams(name=name).to_payload() + data = cast(SnapshotCreateResponseDict, await self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/snapshot/pause", + json=payload, + expected_status=201, + timeout=http_timeout, + )) + return Snapshot.from_dict(data) + + @intercept_errors("Failed to resume snapshot: ") + async def resume( + self, + *, + snapshot_name: str, + auto_pause: bool = False, + timeout_min: int | None = None, + network_policy: NetworkPolicyDict | None = None, + http_timeout: float | None = None, + ) -> AsyncSnapshotSandboxT | Sandbox: + """Restore a sandbox from a snapshot. + + Args: + snapshot_name: Name of the snapshot to restore. + auto_pause: Automatically pause the restored sandbox on timeout. + timeout_min: Sandbox timeout in minutes. + network_policy: Override the network policy from the snapshot. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + Sandbox: Newly resumed sandbox. + """ + payload = ResumeSnapshotParams( + snapshot_name=snapshot_name, + auto_pause=auto_pause, + timeout_min=timeout_min, + network_policy=network_policy, + ).to_payload() + data = cast(SandboxCreateResponseDict, await self._transport.request_json( + "POST", + "/v1/snapshot/resume", + json=payload, + expected_status=201, + timeout=http_timeout, + )) + return self._wrap_sandbox(Sandbox.from_dict(data)) + + @intercept_errors("Failed to delete snapshot: ") + async def delete(self, snapshot: SnapshotRef, http_timeout: float | None = None) -> None: + """ + Delete a snapshot. + + Args: + snapshot: Parameter for this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + await self._transport.request("DELETE", f"/v1/snapshot/{snapshot_id_of(snapshot)}", expected_status=204, timeout=http_timeout) diff --git a/leap0/_async/ssh.py b/leap0/_async/ssh.py new file mode 100644 index 0000000..a934ff5 --- /dev/null +++ b/leap0/_async/ssh.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from typing import cast + +from ._transport import AsyncTransport +from .._utils.errors import intercept_errors +from ..models.sandbox import SandboxRef, sandbox_id_of +from ..models.ssh import SshAccess, SshValidation + + +class AsyncSshClient: + """Manage SSH access credentials for a sandbox. + + Each sandbox supports a single set of SSH credentials at a time. Creating + access when credentials already exist returns 409 Conflict. Use + :meth:`regenerate_access` to rotate credentials without revoking first. + + Example: + ```python + sandbox = client.sandboxes.create() + access = sandbox.ssh.create_access() + print(access.command) + ``` + + Attributes: + None. + """ + + def __init__(self, transport: AsyncTransport): + self._transport = transport + + @intercept_errors("Failed to create SSH access: ") + async def create_access(self, sandbox: SandboxRef, http_timeout: float | None = None) -> SshAccess: + """Generate SSH credentials for a sandbox. + + Returns an access ID (used as the SSH username), a password, and the + full SSH command to connect. + + Args: + sandbox: Sandbox ID or object. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + SshAccess: Generated SSH credential bundle. + """ + data = await self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/ssh/access", expected_status=201, timeout=http_timeout) + return SshAccess.from_dict(cast(dict, data)) + + @intercept_errors("Failed to delete SSH access: ") + async def delete_access(self, sandbox: SandboxRef, http_timeout: float | None = None) -> None: + """Revoke SSH access for a sandbox. The credential is invalidated immediately. + + Args: + sandbox: Sandbox ID or object. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + await self._transport.request("DELETE", f"/v1/sandbox/{sandbox_id_of(sandbox)}/ssh/access", expected_status=204, timeout=http_timeout) + + @intercept_errors("Failed to validate SSH access: ") + async def validate_access(self, sandbox: SandboxRef, *, access_id: str, password: str, http_timeout: float | None = None) -> SshValidation: + """Check whether an SSH access credential is still valid and not expired. + + Args: + sandbox: Sandbox ID or object. + access_id: SSH access identifier. + password: SSH password. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + SshValidation: Validation result for the supplied credential pair. + """ + data = await self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/ssh/validate", + json={"id": access_id, "password": password}, + timeout=http_timeout, + ) + return SshValidation.from_dict(cast(dict, data)) + + @intercept_errors("Failed to regenerate SSH access: ") + async def regenerate_access(self, sandbox: SandboxRef, http_timeout: float | None = None) -> SshAccess: + """Invalidate the current credential and generate a new one. The expiry is also reset. + + Args: + sandbox: Sandbox ID or object. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = await self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/ssh/regen", timeout=http_timeout) + return SshAccess.from_dict(cast(dict, data)) diff --git a/leap0/_async/templates.py b/leap0/_async/templates.py new file mode 100644 index 0000000..bd38b5f --- /dev/null +++ b/leap0/_async/templates.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from typing import cast + +from ._transport import AsyncTransport +from .._utils.errors import intercept_errors +from ..models.template import ( + CreateTemplateParams, + RegistryCredentialsDict, + RenameTemplateParams, + Template, +) +from .._schemas.template import UploadTemplateResponseDict + + +class AsyncTemplatesClient: + """Create, rename, and delete sandbox templates. + + A template is a container image that has been converted into a sandbox + root filesystem. Sandboxes are always created from a template. + + Example: + ```python + template = client.templates.create( + name="my-template", + uri="docker.io/library/python:3.12", + ) + print(template.id) + ``` + + Attributes: + None. + """ + + def __init__(self, transport: AsyncTransport): + self._transport = transport + + @intercept_errors("Failed to create template: ") + async def create(self, *, name: str, uri: str, credentials: RegistryCredentialsDict | None = None) -> Template: + """Upload a new template from a container image URI. + + Args: + name: Template name. Must not start with ``system/`` or contain whitespace. + uri: Container image URI to pull and convert (e.g. ``docker.io/library/python:3.12``). + credentials: Optional registry credentials for private images. + Supports basic, AWS, GCP, and Azure authentication. + + Returns: + Template: Uploaded template metadata. + """ + payload = CreateTemplateParams(name=name, uri=uri, credentials=credentials).to_payload() + data = cast(UploadTemplateResponseDict, await self._transport.request_json("POST", "/v1/template", json=payload, expected_status=201)) + return Template.from_dict(data) + + @intercept_errors("Failed to rename template: ") + async def rename(self, template_id: str, *, name: str, http_timeout: float | None = None) -> None: + """Rename an existing template. + + Args: + template_id: ID of the template to rename. + name: New template name. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + payload = RenameTemplateParams(name=name).to_payload() + await self._transport.request("PATCH", f"/v1/template/{template_id}", json=payload, expected_status=204, timeout=http_timeout) + + @intercept_errors("Failed to delete template: ") + async def delete(self, template_id: str, http_timeout: float | None = None) -> None: + """Delete a template by ID. + + Args: + template_id: ID of the template to delete. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + await self._transport.request("DELETE", f"/v1/template/{template_id}", expected_status=204, timeout=http_timeout) diff --git a/leap0/common/__init__.py b/leap0/_internal/__init__.py similarity index 100% rename from leap0/common/__init__.py rename to leap0/_internal/__init__.py diff --git a/leap0/_internal/types.py b/leap0/_internal/types.py new file mode 100644 index 0000000..4c809e0 --- /dev/null +++ b/leap0/_internal/types.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Protocol, TypeAlias, TypeVar + +JsonPrimitive: TypeAlias = str | int | float | bool | None +JsonValue: TypeAlias = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"] +JsonObject: TypeAlias = dict[str, JsonValue] +BinaryFiles: TypeAlias = list[tuple[str, bytes]] + +SandboxModelT = TypeVar("SandboxModelT") +SandboxReturnT = TypeVar("SandboxReturnT") + + +class SandboxFactory(Protocol[SandboxModelT, SandboxReturnT]): + """Protocol for factory callables that wrap sandbox models.""" + def __call__(self, data: SandboxModelT) -> SandboxReturnT: ... + + +class SyncSandboxService(Protocol): + """Protocol for sandbox-bound synchronous service callables.""" + def __call__(self, sandbox: object, *args: object, **kwargs: object) -> object: ... + + +class AsyncSandboxService(Protocol): + """Protocol for sandbox-bound asynchronous service callables.""" + async def __call__(self, sandbox: object, *args: object, **kwargs: object) -> object: ... + + +class HeaderMapping(Protocol): + """Protocol for mutable HTTP header mappings.""" + + def update(self, other: dict[str, str]) -> None: + """Update the mapping with another header dictionary.""" + ... diff --git a/leap0/_internal/version.py b/leap0/_internal/version.py new file mode 100644 index 0000000..526c248 --- /dev/null +++ b/leap0/_internal/version.py @@ -0,0 +1,6 @@ +from importlib.metadata import PackageNotFoundError, version + +try: + SDK_VERSION = version("leap0") +except PackageNotFoundError: + SDK_VERSION = "unknown" diff --git a/leap0/_schemas/__init__.py b/leap0/_schemas/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/leap0/_schemas/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/leap0/_schemas/code_interpreter.py b/leap0/_schemas/code_interpreter.py new file mode 100644 index 0000000..050b957 --- /dev/null +++ b/leap0/_schemas/code_interpreter.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import Any, TypedDict + +class CodeExecutionOutputDict(TypedDict, total=False): + """Wire schema for one execution output item.""" + 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): + """Wire schema for structured execution errors.""" + name: str + value: str + traceback: str + +class ExecutionLogsDict(TypedDict, total=False): + """Wire schema for execution logs.""" + stdout: list[str] + stderr: list[str] + +class CodeExecutionResultDict(TypedDict, total=False): + """Wire schema for a full execution result.""" + context_id: str + items: list[CodeExecutionOutputDict] + logs: ExecutionLogsDict + error: ExecutionErrorDict | None + execution_count: int | None + +class StreamEventDict(TypedDict, total=False): + """Wire schema for a streamed execution event.""" + type: int | str + data: str + code: int | None + +class CodeContextDict(TypedDict, total=False): + """Wire schema for a code execution context.""" + id: str + language: int | str + cwd: str + +class ListContextsResponseDict(TypedDict): + """Wire schema for listing execution contexts.""" + items: list[CodeContextDict] diff --git a/leap0/_schemas/desktop.py b/leap0/_schemas/desktop.py new file mode 100644 index 0000000..a238d52 --- /dev/null +++ b/leap0/_schemas/desktop.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from typing import Any, Literal, TypedDict, cast + +class DesktopDisplayInfoDict(TypedDict, total=False): + """Wire schema for desktop display information.""" + display: str + width: int + height: int + +class DesktopWindowDict(TypedDict, total=False): + """Wire schema for one desktop window.""" + 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): + """Wire schema for desktop window listings.""" + items: list[DesktopWindowDict] + +class DesktopPointerPositionDict(TypedDict, total=False): + """Wire schema for pointer position.""" + x: int + y: int + +class DesktopRecordingStatusDict(TypedDict, total=False): + """Wire schema for recording status.""" + 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): + """Wire schema for recording summary metadata.""" + id: str + file_name: str + download: str + mime_type: str + size_bytes: int + created_at: str + active: bool + +class DesktopHealthDict(TypedDict, total=False): + """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 + +class DesktopProcessStatusListDict(TypedDict, total=False): + """Wire schema for desktop process status listings.""" + status: str + items: list[DesktopProcessStatusDict] + running: int + total: int + +class DesktopProcessRestartDict(TypedDict, total=False): + """Wire schema for process restart responses.""" + message: str + status: DesktopProcessStatusDict + +class DesktopProcessLogsDict(TypedDict, total=False): + """Wire schema for process logs.""" + process: str + logs: str + +class DesktopProcessErrorsDict(TypedDict, total=False): + """Wire schema for process errors.""" + process: str + errors: str diff --git a/leap0/_schemas/filesystem.py b/leap0/_schemas/filesystem.py new file mode 100644 index 0000000..82c0d6d --- /dev/null +++ b/leap0/_schemas/filesystem.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import TypedDict + +class FileInfoDict(TypedDict, total=False): + """Wire schema for file metadata.""" + 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): + """Wire schema for directory listings.""" + items: list[FileInfoDict] + +class GlobResponseDict(TypedDict): + """Wire schema for glob results.""" + items: list[str] + +class SearchMatchDict(TypedDict, total=False): + """Wire schema for a filesystem search match.""" + path: str + line: int + content: str + +class GrepResponseDict(TypedDict): + """Wire schema for grep results.""" + items: list[SearchMatchDict] + +class EditFileResponseDict(TypedDict, total=False): + """Wire schema for a single-file edit response.""" + diff: str + replacements: int + +class EditResultDict(TypedDict, total=False): + """Wire schema for a per-file edit result.""" + file: str + success: bool + error: str + +class EditFilesResponseDict(TypedDict): + """Wire schema for a multi-file edit response.""" + items: list[EditResultDict] + +class ExistsResponseDict(TypedDict): + """Wire schema for path existence checks.""" + exists: bool + +class TreeEntryDict(TypedDict, total=False): + """Wire schema for one tree entry.""" + name: str + type: str + children: list[TreeEntryDict] + +class TreeResponseDict(TypedDict): + """Wire schema for recursive tree results.""" + items: list[TreeEntryDict] diff --git a/leap0/_schemas/git.py b/leap0/_schemas/git.py new file mode 100644 index 0000000..8ccce6b --- /dev/null +++ b/leap0/_schemas/git.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import TypedDict + +class GitResultDict(TypedDict, total=False): + """Wire schema for a generic Git operation response.""" + output: str + exit_code: int + +class GitCommitResponseDict(TypedDict, total=False): + """Wire schema for a Git commit response.""" + sha: str | None + result: GitResultDict | None diff --git a/leap0/_schemas/lsp.py b/leap0/_schemas/lsp.py new file mode 100644 index 0000000..c05f2b7 --- /dev/null +++ b/leap0/_schemas/lsp.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Any, TypedDict + +class LspSuccessResponseDict(TypedDict, total=False): + """Wire schema for basic LSP success responses.""" + success: bool + +class LspJsonRpcErrorDict(TypedDict, total=False): + """Wire schema for LSP JSON-RPC errors.""" + code: int + message: str + data: Any + +class LspJsonRpcResponseDict(TypedDict, total=False): + """Wire schema for LSP JSON-RPC responses.""" + jsonrpc: str + id: int | str | None + result: Any + error: LspJsonRpcErrorDict diff --git a/leap0/_schemas/process.py b/leap0/_schemas/process.py new file mode 100644 index 0000000..554823c --- /dev/null +++ b/leap0/_schemas/process.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from typing import TypedDict + +class ProcessResultDict(TypedDict, total=False): + """Wire schema for process execution results.""" + exit_code: int + result: str diff --git a/leap0/_schemas/pty.py b/leap0/_schemas/pty.py new file mode 100644 index 0000000..7248e82 --- /dev/null +++ b/leap0/_schemas/pty.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import TypedDict + +class PtySessionInfoDict(TypedDict, total=False): + """Wire schema for PTY session metadata.""" + 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): + """Wire schema for PTY session listings.""" + items: list[PtySessionInfoDict] diff --git a/leap0/_schemas/sandbox.py b/leap0/_schemas/sandbox.py new file mode 100644 index 0000000..decbf5e --- /dev/null +++ b/leap0/_schemas/sandbox.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, TypedDict +from typing_extensions import NotRequired, Required + +if TYPE_CHECKING: + from ..models.sandbox import NetworkPolicyMode, SandboxState + +class TransformRuleDict(TypedDict, total=False): + """Wire schema for network transform rules.""" + domain: Required[str] + inject_headers: NotRequired[dict[str, str]] + strip_headers: NotRequired[list[str]] + +class NetworkPolicyDict(TypedDict, total=False): + """Wire schema for sandbox network policy.""" + mode: Required[NetworkPolicyMode | str] + allow_domains: NotRequired[list[str]] + allow_cidrs: NotRequired[list[str]] + transforms: NotRequired[list[TransformRuleDict]] + +class SandboxCreateResponseDict(TypedDict): + """Wire schema for sandbox creation responses.""" + id: str + template_id: str + vcpu: int + memory_mib: int + disk_mib: int + state: SandboxState | str + auto_pause: bool + created_at: str + network_policy: NetworkPolicyDict | None + +class SandboxStatusResponseDict(TypedDict): + """Wire schema for sandbox status responses.""" + id: str + template_id: str + vcpu: int + memory_mib: int + disk_mib: int + state: SandboxState | str + auto_pause: bool + created_at: str diff --git a/leap0/_schemas/snapshot.py b/leap0/_schemas/snapshot.py new file mode 100644 index 0000000..3f77a14 --- /dev/null +++ b/leap0/_schemas/snapshot.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from typing import TypedDict + +class SnapshotCreateResponseDict(TypedDict, total=False): + """Wire schema for snapshot creation responses.""" + snapshot_id: str + name: str + template_id: str + vcpu: int + memory_mib: int + disk_mib: int + state: SandboxState | str + created_at: str + network_policy: NetworkPolicyDict | None diff --git a/leap0/_schemas/ssh.py b/leap0/_schemas/ssh.py new file mode 100644 index 0000000..f36df50 --- /dev/null +++ b/leap0/_schemas/ssh.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import TypedDict + +class SshCreateAccessDict(TypedDict, total=False): + """Wire schema for SSH access creation responses.""" + id: str + sandbox_id: str + password: str + expires_at: str + created_at: str + updated_at: str + ssh_command: str + +class SshAccessValidationDict(TypedDict, total=False): + """Wire schema for SSH validation responses.""" + valid: bool + sandbox_id: str diff --git a/leap0/_schemas/template.py b/leap0/_schemas/template.py new file mode 100644 index 0000000..3400f42 --- /dev/null +++ b/leap0/_schemas/template.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import TypedDict +from typing_extensions import Literal, NotRequired, Required, TypeAlias + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..models.template import RegistryCredentialType + +class BasicRegistryCredentialsDict(TypedDict, total=False): + """Wire schema for basic registry credentials.""" + type: Required[Literal[RegistryCredentialType.BASIC, "basic"]] + username: Required[str] + password: Required[str] + +class AwsRegistryCredentialsDict(TypedDict, total=False): + """Wire schema for AWS registry credentials.""" + 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): + """Wire schema for GCP registry credentials.""" + type: Required[Literal[RegistryCredentialType.GCP, "gcp"]] + gcp_service_account_json: Required[str] + +class AzureRegistryCredentialsDict(TypedDict, total=False): + """Wire schema for Azure registry credentials.""" + 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): + """Wire schema for image configuration.""" + entrypoint: list[str] | None + cmd: list[str] | None + working_dir: str + user: str + env: dict[str, str] | None + +class UploadTemplateResponseDict(TypedDict): + """Wire schema for template upload responses.""" + id: str + name: str + digest: str + image_config: ImageConfigDict | None + is_system: bool + created_at: str diff --git a/leap0/_sync/__init__.py b/leap0/_sync/__init__.py new file mode 100644 index 0000000..aac6a59 --- /dev/null +++ b/leap0/_sync/__init__.py @@ -0,0 +1,29 @@ +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 .sandbox import Sandbox, SandboxesClient +from .snapshots import SnapshotsClient +from .ssh import SshClient +from .templates import TemplatesClient + +__all__ = [ + "CodeInterpreterClient", + "DesktopClient", + "FilesystemClient", + "GitClient", + "Leap0", + "Leap0Client", + "LspClient", + "ProcessClient", + "PtyClient", + "Sandbox", + "SandboxesClient", + "SnapshotsClient", + "SshClient", + "TemplatesClient", +] diff --git a/leap0/_sync/_transport.py b/leap0/_sync/_transport.py new file mode 100644 index 0000000..239408d --- /dev/null +++ b/leap0/_sync/_transport.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from typing import BinaryIO + +import httpx + +from .._internal.types import BinaryFiles, JsonObject +from .._utils.otel import with_instrumentation +from .._internal.version import SDK_VERSION +from ..models.config import DEFAULT_CLIENT_TIMEOUT +from ..models.errors import raise_api_error + + + +class Transport: + """HTTP transport for synchronous SDK requests. + + Attributes: + api_key: Public attribute exposed by this object. + base_url: Public attribute exposed by this object. + timeout: Public attribute exposed by this object. + auth_header: Public attribute exposed by this object. + bearer: Public attribute exposed by this object. + """ + def __init__( + self, + *, + api_key: str, + base_url: str, + timeout: float = DEFAULT_CLIENT_TIMEOUT, + auth_header: str = "authorization", + bearer: bool = True, + ): + self.api_key = api_key + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.auth_header = auth_header + self.bearer = bearer + self._client = httpx.Client(timeout=timeout) + + _timeout_override: ContextVar[float | None] = ContextVar("leap0_sync_timeout_override", default=None) + + @property + def auth_value(self) -> str: + """Return the formatted authorization header value. + + Returns: + object: Result returned by this operation. + """ + if self.bearer and not self.api_key.lower().startswith("bearer "): + return f"Bearer {self.api_key}" + return self.api_key + + def close(self) -> None: + """Close the client and release resources.""" + self._client.close() + + @contextmanager + def override_timeout(self, timeout: float | None): + """Temporarily override the transport timeout for nested calls. + + Args: + timeout: Operation timeout in seconds. + + Yields: + object: Items yielded by this operation. + """ + if timeout is None: + yield + return + token = self._timeout_override.set(timeout) + try: + yield + finally: + self._timeout_override.reset(token) + + def headers(self, extra: dict[str, str] | None = None) -> dict[str, str]: + """Build request headers for the current transport. + + Args: + extra: Parameter for this operation. + + Returns: + object: Result returned by this operation. + """ + headers = { + self.auth_header: self.auth_value, + "Leap0-Source": "sdk-python", + "Leap0-SDK-Version": SDK_VERSION, + "User-Agent": f"leap0-python/{SDK_VERSION}", + } + if extra: + headers.update(extra) + return headers + + def _expected(self, expected_status: int | tuple[int, ...]) -> tuple[int, ...]: + return (expected_status,) if isinstance(expected_status, int) else expected_status + + def _target_url(self, target: str) -> str: + if target.startswith("https://") or target.startswith("http://"): + return target + return f"{self.base_url}{target}" + + 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_api_error( + response.status_code, + f"Request failed: {method} {target}", + body=response.text, + headers=dict(response.headers), + ) + return response + + @with_instrumentation("transport.target_request") + def _request( + self, + method: str, + target: str, + *, + params: JsonObject | None = None, + json: JsonObject | None = None, + content: bytes | str | BinaryIO | None = None, + files: BinaryFiles | None = None, + headers: dict[str, str] | None = None, + expected_status: int | tuple[int, ...] = 200, + timeout: float | None = None, + ) -> httpx.Response: + actual_target = self._target_url(target) + response = self._client.request( + method, + actual_target, + params=params, + json=json, + content=content, + files=files, + headers=self.headers(headers), + timeout=timeout or self._timeout_override.get() or self.timeout, + ) + return self._check_response(response, method, target, expected_status) + + @with_instrumentation("transport.stream") + def _stream( + self, + method: str, + target: str, + *, + json: JsonObject | None = None, + timeout: float | None = None, + ) -> httpx.Response: + effective = timeout if timeout is not None else (self._timeout_override.get() or self.timeout) + timeout_dict = {"connect": effective, "read": effective, "write": effective, "pool": effective} + request = self._client.build_request( + method, + self._target_url(target), + json=json, + headers=self.headers(), + extensions={"timeout": timeout_dict}, + ) + 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_api_error(response.status_code, f"Request failed: {method} {target}", body=body, headers=hdrs) + return response + + @with_instrumentation("transport.request") + def request( + self, + method: str, + path: str, + *, + params: JsonObject | None = None, + json: JsonObject | None = None, + content: bytes | str | BinaryIO | None = None, + files: BinaryFiles | None = None, + headers: dict[str, str] | None = None, + expected_status: int | tuple[int, ...] = 200, + timeout: float | None = None, + ) -> httpx.Response: + """Send an HTTP request to a control-plane path. + + Args: + method: Parameter for this operation. + path: Path used by this operation. + params: Parameter for this operation. + json: Parameter for this operation. + content: Parameter for this operation. + files: Parameter for this operation. + headers: Parameter for this operation. + expected_status: Parameter for this operation. + timeout: Operation timeout in seconds. + + Returns: + object: Result returned by this operation. + """ + url = f"{self.base_url}{path}" + actual_headers = self.headers(headers) + response = self._client.request( + method, + url, + params=params, + json=json, + content=content, + files=files, + headers=actual_headers, + timeout=timeout or self.timeout, + ) + return self._check_response(response, method, path, expected_status) + + @with_instrumentation("transport.request_json") + def request_json( + self, + method: str, + path: str, + *, + params: JsonObject | None = None, + json: JsonObject | None = None, + content: bytes | str | BinaryIO | None = None, + headers: dict[str, str] | None = None, + expected_status: int | tuple[int, ...] = 200, + timeout: float | None = None, + ) -> JsonObject: + """Like request() but parses and returns the JSON body as a dict. + + Args: + method: Parameter for this operation. + path: Path used by this operation. + params: Parameter for this operation. + json: Parameter for this operation. + content: Parameter for this operation. + headers: Parameter for this operation. + expected_status: Parameter for this operation. + timeout: Operation timeout in seconds. + + Returns: + object: Result returned by this operation. + """ + resp = self.request( + method, + path, + params=params, + json=json, + content=content, + headers=headers, + expected_status=expected_status, + timeout=timeout, + ) + return resp.json() + + @with_instrumentation("transport.request_target") + def request_target( + self, + method: str, + target: str, + *, + params: JsonObject | None = None, + json: JsonObject | None = None, + expected_status: int | tuple[int, ...] = 200, + ) -> httpx.Response: + """Send a request to an absolute URL (e.g. sandbox-domain URLs). + + Args: + method: Parameter for this operation. + target: Parameter for this operation. + params: Parameter for this operation. + json: Parameter for this operation. + expected_status: Parameter for this operation. + + Returns: + object: Result returned by this operation. + """ + return self._request(method, target, params=params, json=json, expected_status=expected_status) + + @with_instrumentation("transport.request_target_json") + def request_target_json( + self, + method: str, + target: str, + *, + params: JsonObject | None = None, + json: JsonObject | None = None, + expected_status: int | tuple[int, ...] = 200, + ) -> JsonObject: + """Send a request to an absolute URL and return parsed JSON. + + Args: + method: Parameter for this operation. + target: Parameter for this operation. + params: Parameter for this operation. + json: Parameter for this operation. + expected_status: Parameter for this operation. + + Returns: + object: Result returned by this operation. + """ + resp = self._request(method, target, params=params, json=json, expected_status=expected_status) + return resp.json() + + def stream(self, method: str, target: str, *, json: JsonObject | None = None, timeout: float | None = None) -> httpx.Response: + """Open a streaming HTTP response. + + Args: + method: Parameter for this operation. + target: Parameter for this operation. + json: Parameter for this operation. + timeout: Operation timeout in seconds. + + Returns: + object: Result returned by this operation. + """ + return self._stream(method, target, json=json, timeout=timeout) diff --git a/leap0/_sync/client.py b/leap0/_sync/client.py new file mode 100644 index 0000000..c21b65c --- /dev/null +++ b/leap0/_sync/client.py @@ -0,0 +1,208 @@ +from __future__ import annotations +from types import TracebackType +from typing import Self + +from opentelemetry import metrics, trace +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.semconv.attributes import service_attributes + +from ._transport import Transport +from .._utils.otel import with_instrumentation +from .code_interpreter import CodeInterpreterClient +from .desktop import DesktopClient +from ..models.config import ( + DEFAULT_BASE_URL, + DEFAULT_CLIENT_TIMEOUT, + DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME, + 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 +from .lsp import LspClient +from .process import ProcessClient +from .pty import PtyClient +from .sandbox import Sandbox, SandboxesClient +from .ssh import SshClient +from .snapshots import SnapshotsClient +from .templates import TemplatesClient + + +class Leap0Client: + """Top-level client for the Leap0 API. + + Use this client to create sandboxes and access all service clients. It can + be used directly or as a context manager. + + Args: + api_key: API key for authentication. Falls back to ``LEAP0_API_KEY``. + base_url: Control-plane base URL. Falls back to ``LEAP0_BASE_URL``. + sandbox_domain: Sandbox domain suffix. Falls back to + ``LEAP0_SANDBOX_DOMAIN``. + timeout: Default HTTP timeout in seconds. + auth_header: Header name used to send the API key. + bearer: Whether to prefix the API key with ``Bearer``. + + Attributes: + sandboxes: Client for sandbox lifecycle operations. + snapshots: Client for snapshot lifecycle operations. + templates: Client for template management. + filesystem: Client for sandbox filesystem operations. + git: Client for Git operations inside a sandbox. + process: Client for one-shot process execution. + pty: Client for interactive PTY sessions. + lsp: Client for Language Server Protocol operations. + ssh: Client for SSH credential management. + code_interpreter: Client for code execution APIs. + desktop: Client for desktop automation APIs. + """ + DEFAULT_BASE_URL = DEFAULT_BASE_URL + DEFAULT_SANDBOX_DOMAIN = DEFAULT_SANDBOX_DOMAIN + DEFAULT_TEMPLATE_NAME = DEFAULT_TEMPLATE_NAME + DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME = DEFAULT_CODE_INTERPRETER_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 + + _tracer_provider: TracerProvider | None = None + _meter_provider: MeterProvider | None = None + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + sandbox_domain: str | None = None, + timeout: float = DEFAULT_CLIENT_TIMEOUT, + auth_header: str = "authorization", + bearer: bool = True, + otel_enabled: bool | None = None, + ): + config = Leap0Config( + api_key=api_key, + base_url=base_url, + sandbox_domain=sandbox_domain, + timeout=timeout, + auth_header=auth_header, + bearer=bearer, + otel_enabled=otel_enabled, + ) + self._transport = Transport( + api_key=config.api_key, + base_url=config.base_url, + timeout=config.timeout, + auth_header=config.auth_header, + bearer=config.bearer, + ) + self.sandboxes: SandboxesClient[Sandbox] = SandboxesClient( + self._transport, + sandbox_domain=config.sandbox_domain, + sandbox_factory=lambda data: Sandbox(self, data), + ) + self.snapshots: SnapshotsClient[Sandbox] = SnapshotsClient( + self._transport, + sandbox_factory=lambda data: Sandbox(self, data), + ) + self.templates = TemplatesClient(self._transport) + self.filesystem = FilesystemClient(self._transport) + self.git = GitClient(self._transport) + self.process = ProcessClient(self._transport) + self.pty = PtyClient(self._transport) + self.lsp = LspClient(self._transport) + self.ssh = SshClient(self._transport) + self.code_interpreter = CodeInterpreterClient(self._transport, sandbox_domain=config.sandbox_domain) + self.desktop = DesktopClient(self._transport, sandbox_domain=config.sandbox_domain) + + if config.otel_enabled: + self._init_otel() + + def _init_otel(self) -> None: + resource = Resource.create( + { + service_attributes.SERVICE_NAME: "leap0-python-sdk", + service_attributes.SERVICE_VERSION: self._transport.headers().get("Leap0-SDK-Version", "unknown"), + } + ) + self._tracer_provider = TracerProvider(resource=resource) + self._tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + trace.set_tracer_provider(self._tracer_provider) + self._meter_provider = MeterProvider(resource=resource) + metrics.set_meter_provider(self._meter_provider) + + @with_instrumentation("client.get_sandbox") + def get_sandbox(self, sandbox_id: str) -> Sandbox: + """Get a sandbox object with bound service clients by ID. + + Args: + sandbox_id: Sandbox identifier. + + Returns: + Sandbox: Sandbox object with bound service clients. + """ + return self.sandboxes.get(sandbox_id) + + @with_instrumentation("client.create_sandbox") + def create_sandbox(self, **kwargs: object) -> Sandbox: + """Create a sandbox and return it as a bound sandbox object. + + Args: + **kwargs: Keyword arguments forwarded to ``client.sandboxes.create``. + + Returns: + Sandbox: Sandbox object with bound service clients. + """ + return self.sandboxes.create(**kwargs) + + @with_instrumentation("client.close") + def close(self) -> None: + """Close the underlying HTTP transport. + + Call this when you are done with the client if you are not using a + context manager. + """ + self._transport.close() + if self._tracer_provider is not None: + self._tracer_provider.shutdown() + if self._meter_provider is not None: + self._meter_provider.shutdown() + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + + +def Leap0(config: Leap0Config) -> Leap0Client: + """Create a synchronous Leap0 client from a config object. + + Args: + config: Fully resolved Leap0 client configuration. + + Returns: + Leap0Client: Configured synchronous client instance. + """ + return Leap0Client( + api_key=config.api_key, + base_url=config.base_url, + sandbox_domain=config.sandbox_domain, + timeout=config.timeout, + auth_header=config.auth_header, + bearer=config.bearer, + otel_enabled=config.otel_enabled, + ) diff --git a/leap0/_sync/code_interpreter.py b/leap0/_sync/code_interpreter.py new file mode 100644 index 0000000..c804aaf --- /dev/null +++ b/leap0/_sync/code_interpreter.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any, cast + +import httpx + +from .._internal.types import JsonObject +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 ..models.code_interpreter import ( + CodeContext, CodeContextDict, CodeExecutionResult, CodeExecutionResultDict, StreamEvent, StreamEventDict, +) +from ..models.sandbox import SandboxRef, sandbox_id_of + + +class CodeInterpreterClient: + """Execute code inside a sandbox using a managed interpreter runtime. + + Supports Python and TypeScript/JavaScript. Each execution can be linked + to a persistent context to share state across multiple calls. + + Example: + ```python + result = client.code_interpreter.execute( + sandbox, + code="sum([1, 2, 3])", + language="python", + ) + print(result.main_text) + ``` + + Attributes: + None. + """ + + def __init__(self, transport: Transport, *, sandbox_domain: str | None = None): + self._transport = transport + self._sandbox_domain = sandbox_domain.strip("/") if sandbox_domain else None + + def _request( + self, + method: str, + sandbox: SandboxRef, + path: str, + *, + json: JsonObject | None = None, + expected_status: int | tuple[int, ...] = 200, + http_timeout: float | None = None, + ) -> httpx.Response: + return self._transport.request_target( + method, + f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", + json=json, + expected_status=expected_status, + timeout=http_timeout, + ) + + def _request_json( + self, + method: str, + sandbox: SandboxRef, + path: str, + *, + json: JsonObject | None = None, + expected_status: int | tuple[int, ...] = 200, + http_timeout: float | None = None, + ) -> JsonObject: + return self._transport.request_target_json( + method, + f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", + json=json, + expected_status=expected_status, + timeout=http_timeout, + ) + + @intercept_errors("Failed to check interpreter health: ") + def health(self, sandbox: SandboxRef, http_timeout: float | None = None) -> bool: + """Check whether the code interpreter is healthy. + + Args: + sandbox: Sandbox ID or object. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + sandbox: Sandbox ID or object. + language: Language runtime (e.g. ``"python"``, ``"typescript"``). + cwd: Working directory (default ``"/home/user"``). + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Returns: + CodeContext: Newly created persistent execution context. + + Returns: + bool: ``True`` when the service reports ``"ok"``. + """ + data = self._request_json("GET", sandbox, "/healthz", http_timeout=http_timeout) + 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, http_timeout: float | None = None) -> CodeContext: + """Create a new execution context. + + Args: + sandbox: Sandbox ID or object. + language: Language runtime for the operation. + cwd: Working directory for the operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"language": language} + if cwd is not None: + payload["cwd"] = cwd + data = cast(CodeContextDict, self._request_json("POST", sandbox, "/contexts", json=payload, expected_status=201, http_timeout=http_timeout)) + return CodeContext.from_dict(data) + + @intercept_errors("Failed to list execution contexts: ") + def list_contexts(self, sandbox: SandboxRef, http_timeout: float | None = None) -> list[CodeContext]: + """List all execution contexts in the sandbox. + + Args: + sandbox: Sandbox ID or object. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + sandbox: Sandbox ID or object. + context_id: Execution context ID. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + CodeContext: Matching execution context. + + Returns: + list[CodeContext]: Active execution contexts. + """ + raw = self._request_json("GET", sandbox, "/contexts", http_timeout=http_timeout) + # Server wraps response in {"items": [...]} + items = cast(list[CodeContextDict], raw.get("items", [])) + 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, http_timeout: float | None = None) -> CodeContext: + """Get a single execution context by ID. + + Args: + sandbox: Sandbox ID or object. + context_id: Parameter for this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = cast(CodeContextDict, self._request_json("GET", sandbox, f"/contexts/{context_id}", http_timeout=http_timeout)) + 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. + + Args: + sandbox: Sandbox ID or object. + context_id: Parameter for this operation. + """ + self._request("DELETE", sandbox, f"/contexts/{context_id}", expected_status=204) + + @intercept_errors("Failed to execute code: ") + def execute( + self, + sandbox: SandboxRef, + *, + code: str, + language: str = "python", + context_id: str | None = None, + env_vars: dict[str, str] | None = None, + timeout_ms: int | None = None, + http_timeout: float | None = None, + ) -> CodeExecutionResult: + """Execute code and wait for the full result. + + Args: + sandbox: Sandbox ID or object. + code: Source code to execute. + language: Language runtime (default ``"python"``). + context_id: Link to an existing context to share state. + Auto-generated if omitted. + env_vars: Environment variables for the execution. + timeout_ms: Execution timeout in milliseconds (default 30000). + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + sandbox: Sandbox ID or object. + code: Source code to execute. + language: Language runtime (default ``"python"``). + context_id: Link to an existing context to share state. + timeout_ms: Execution timeout in milliseconds (default 30000). + + Args: + sandbox: Sandbox ID or object. + code: Source code to execute. + language: Language runtime (default ``"python"``). + context_id: Link to an existing context to share state. + timeout_ms: Execution timeout in milliseconds (default 30000). + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Yields: + StreamEvent: Streaming stdout, stderr, exit, and error events. + + Returns: + CodeExecutionResult: Structured execution output, errors, and logs. + """ + payload: JsonObject = {"code": code, "language": language} + if context_id is not None: + payload["context_id"] = context_id + if env_vars is not None: + payload["env_vars"] = env_vars + if timeout_ms is not None: + payload["timeout_ms"] = timeout_ms + response = self._request("POST", sandbox, "/execute", json=payload, http_timeout=http_timeout) + data = cast(CodeExecutionResultDict, response.json()) + return CodeExecutionResult.from_dict(data) + + @intercept_errors("Failed to execute code: ") + def execute_stream( + self, + sandbox: SandboxRef, + *, + code: str, + language: str = "python", + context_id: str | None = None, + timeout_ms: int | None = None, + http_timeout: float | None = None, + ) -> Iterator[StreamEvent]: + """Execute code and stream output events via SSE. + + Yields :class:`StreamEvent` objects with type ``"stdout"``, + ``"stderr"``, ``"exit"``, or ``"error"``. + + Args: + sandbox: Sandbox ID or object. + code: Source code to execute. + language: Language runtime for the operation. + context_id: Parameter for this operation. + timeout_ms: Execution timeout in milliseconds. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Yields: + object: Items yielded by this operation. + """ + payload: JsonObject = {"code": code, "language": language} + if context_id is not None: + payload["context_id"] = context_id + if timeout_ms is not None: + payload["timeout_ms"] = timeout_ms + response = self._transport.stream( + "POST", + f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}/execute/async", + json=payload, + timeout=http_timeout, + ) + try: + for event in iter_sse_events(response.iter_lines()): + yield StreamEvent.from_dict(cast(StreamEventDict, event)) + finally: + response.close() diff --git a/leap0/_sync/desktop.py b/leap0/_sync/desktop.py new file mode 100644 index 0000000..e51a2bb --- /dev/null +++ b/leap0/_sync/desktop.py @@ -0,0 +1,655 @@ +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, stop_after_delay, wait_exponential + +from .._internal.types import JsonObject +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 ..models.errors import Leap0Error, Leap0TimeoutError +from ..models.desktop import ( + DesktopDisplayInfo, + DesktopDisplayInfoDict, + DesktopHealth, + DesktopHealthDict, + DesktopPointerPosition, + DesktopPointerPositionDict, + DesktopProcessErrors, + DesktopProcessErrorsDict, + DesktopProcessLogs, + DesktopProcessLogsDict, + DesktopProcessRestart, + DesktopProcessRestartDict, + DesktopProcessStatus, + DesktopProcessStatusDict, + DesktopProcessStatusList, + DesktopProcessStatusListDict, + DesktopRecordingStatus, + DesktopRecordingStatusDict, + DesktopRecordingSummary, + DesktopRecordingSummaryDict, + DesktopWindow, + DesktopWindowsDict, +) +from ..models.sandbox import SandboxRef, sandbox_id_of + + +class DesktopClient: + """Control a graphical Linux desktop inside a sandbox. + + 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. + + Attributes: + None. + """ + + def __init__(self, transport: Transport, *, sandbox_domain: str | None = None): + self._transport = transport + self._sandbox_domain = sandbox_domain.strip("/") if sandbox_domain else None + + def _request( + self, + method: str, + sandbox: SandboxRef, + path: str, + *, + params: JsonObject | None = None, + json: JsonObject | None = None, + expected_status: int | tuple[int, ...] = 200, + http_timeout: float | None = None, + ) -> httpx.Response: + return self._transport.request_target( + method, + f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", + params=params, + json=json, + expected_status=expected_status, + timeout=http_timeout, + ) + + def _request_json( + self, + method: str, + sandbox: SandboxRef, + path: str, + *, + params: JsonObject | None = None, + json: JsonObject | None = None, + expected_status: int | tuple[int, ...] = 200, + http_timeout: float | None = None, + ) -> JsonObject: + return self._transport.request_target_json( + method, + f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", + params=params, + json=json, + expected_status=expected_status, + timeout=http_timeout, + ) + + def desktop_url(self, sandbox: SandboxRef) -> str: + """Build the browser URL for the noVNC desktop viewer. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + 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). + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopDisplayInfoDict, self._request_json("GET", sandbox, "/api/display")) + return DesktopDisplayInfo.from_dict(data) + + @intercept_errors("Failed to get screen info: ") + def screen(self, sandbox: SandboxRef) -> DesktopDisplayInfo: + """Get the current screen resolution. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopDisplayInfoDict, self._request_json("GET", sandbox, "/api/display/screen")) + return DesktopDisplayInfo.from_dict(data) + + @intercept_errors("Failed to resize screen: ") + def resize_screen(self, sandbox: SandboxRef, *, width: int, height: int, http_timeout: float | None = None) -> DesktopDisplayInfo: + """ + Resize the virtual display (width: 320-7680, height: 320-4320). + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopDisplayInfoDict, self._request_json( + "POST", + sandbox, + "/api/display/screen", + json={"width": width, "height": height}, + http_timeout=http_timeout, + )) + 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. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopWindowsDict, self._request_json("GET", sandbox, "/api/display/windows")) + return [DesktopWindow.from_dict(item) for item in data.get("items", [])] + + @intercept_errors("Failed to take screenshot: ") + def screenshot( + self, + sandbox: SandboxRef, + *, + image_format: str | None = None, + quality: int | None = None, + x: int | None = None, + y: int | None = None, + width: int | None = None, + height: int | None = None, + http_timeout: float | None = None, + ) -> bytes: + """Take a screenshot and return the image as bytes. + + Args: + sandbox: Sandbox ID or object. + image_format: ``"png"``, ``"jpg"``, or ``"jpeg"`` (default ``"png"``). + quality: JPEG quality (1-100). + x: Left edge of capture region. + y: Top edge of capture region. + width: Region width in pixels. + height: Region height in pixels. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + 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 + response = self._request("GET", sandbox, "/api/screenshot", params=params or None, http_timeout=http_timeout) + return response.content + + @intercept_errors("Failed to take screenshot: ") + def screenshot_region( + self, + sandbox: SandboxRef, + *, + x: int, + y: int, + width: int, + height: int, + image_format: str | None = None, + quality: int | None = None, + http_timeout: float | None = None, + ) -> bytes: + """ + Take a screenshot of a specific region and return the image as bytes. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + 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 + response = self._request("POST", sandbox, "/api/screenshot/region", json=payload, http_timeout=http_timeout) + return response.content + + @intercept_errors("Failed to get pointer position: ") + def pointer_position(self, sandbox: SandboxRef) -> DesktopPointerPosition: + """Get the current mouse pointer position. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopPointerPositionDict, self._request_json("GET", sandbox, "/api/input/position")) + return DesktopPointerPosition.from_dict(data) + + @intercept_errors("Failed to move pointer: ") + def move_pointer(self, sandbox: SandboxRef, *, x: int, y: int, http_timeout: float | None = None) -> DesktopPointerPosition: + """ + Move the mouse pointer to the given coordinates. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopPointerPositionDict, self._request_json("POST", sandbox, "/api/input/move", json={"x": x, "y": y}, http_timeout=http_timeout)) + return DesktopPointerPosition.from_dict(data) + + @intercept_errors("Failed to click: ") + def click( + self, + sandbox: SandboxRef, + *, + x: int | None = None, + y: int | None = None, + button: int | None = None, + http_timeout: float | None = None, + ) -> DesktopPointerPosition: + """Click the mouse. Clicks at the current position if coordinates are omitted. + + Args: + sandbox: Sandbox ID or object. + x: X coordinate. + y: Y coordinate. + button: 1=left, 2=middle, 3=right (default 1). + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + 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 + data = cast(DesktopPointerPositionDict, self._request_json("POST", sandbox, "/api/input/click", json=payload, http_timeout=http_timeout)) + return DesktopPointerPosition.from_dict(data) + + @intercept_errors("Failed to drag: ") + def drag( + self, + sandbox: SandboxRef, + *, + from_x: int, + from_y: int, + to_x: int, + to_y: int, + button: int | None = None, + http_timeout: float | None = None, + ) -> DesktopPointerPosition: + """ + Drag from one position to another. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = { + "from_x": from_x, + "from_y": from_y, + "to_x": to_x, + "to_y": to_y, + } + if button is not None: + payload["button"] = button + data = cast(DesktopPointerPositionDict, self._request_json("POST", sandbox, "/api/input/drag", json=payload, http_timeout=http_timeout)) + return DesktopPointerPosition.from_dict(data) + + @intercept_errors("Failed to scroll: ") + def scroll(self, sandbox: SandboxRef, *, direction: str, amount: int | None = None, http_timeout: float | None = None) -> DesktopPointerPosition: + """Scroll the mouse wheel. + + Args: + sandbox: Sandbox ID or object. + direction: ``"up"``, ``"down"``, ``"left"``, or ``"right"``. + amount: Number of scroll steps (1-100, default 1). + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"direction": direction} + if amount is not None: + payload["amount"] = amount + data = cast(DesktopPointerPositionDict, self._request_json("POST", sandbox, "/api/input/scroll", json=payload, http_timeout=http_timeout)) + 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). + + Args: + sandbox: Sandbox ID or object. + text: Parameter for this operation. + + Returns: + object: Result returned by this operation. + """ + 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, http_timeout: float | None = None) -> bool: + """ + Press a single key by X11 keysym name (e.g. ``"Return"``, ``"Escape"``). + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = self._request_json("POST", sandbox, "/api/input/press", json={"key": key}, http_timeout=http_timeout) + 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"]``). + + Args: + sandbox: Sandbox ID or object. + keys: Parameter for this operation. + + Returns: + object: Result returned by this operation. + """ + 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, http_timeout: float | None = None) -> DesktopRecordingStatus: + """ + Get the current screen recording status. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopRecordingStatusDict, self._request_json("GET", sandbox, "/api/recording", http_timeout=http_timeout)) + 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. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopRecordingStatusDict, self._request_json("POST", sandbox, "/api/recording/start", expected_status=201)) + return DesktopRecordingStatus.from_dict(data) + + @intercept_errors("Failed to stop recording: ") + def stop_recording(self, sandbox: SandboxRef, http_timeout: float | None = None) -> DesktopRecordingStatus: + """ + Stop the active screen recording. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopRecordingStatusDict, self._request_json("POST", sandbox, "/api/recording/stop", http_timeout=http_timeout)) + return DesktopRecordingStatus.from_dict(data) + + @intercept_errors("Failed to list recordings: ") + def recordings(self, sandbox: SandboxRef) -> list[DesktopRecordingSummary]: + """List all screen recordings. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + raw = self._request_json("GET", sandbox, "/api/recordings") + items = cast(list[DesktopRecordingSummaryDict], raw.get("items", [])) + return [DesktopRecordingSummary.from_dict(item) for item in items] + + @intercept_errors("Failed to get recording: ") + def get_recording(self, sandbox: SandboxRef, recording_id: str, http_timeout: float | None = None) -> DesktopRecordingSummary: + """ + Get details for a single recording. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopRecordingSummaryDict, self._request_json("GET", sandbox, f"/api/recordings/{recording_id}", http_timeout=http_timeout)) + 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. + + Args: + sandbox: Sandbox ID or object. + recording_id: Recording identifier. + + Returns: + object: Result returned by this operation. + """ + 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, http_timeout: float | None = None) -> None: + """ + Delete a recording. Cannot delete an active recording. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + self._request("DELETE", sandbox, f"/api/recordings/{recording_id}", expected_status=204, http_timeout=http_timeout) + + @intercept_errors("Failed to check desktop health: ") + def health(self, sandbox: SandboxRef) -> DesktopHealth: + """Check the health of the desktop environment. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopHealthDict, self._request_json("GET", sandbox, "/api/healthz", expected_status=(200, 503))) + return DesktopHealth.from_dict(data) + + @intercept_errors("Failed to get process status: ") + def process_status(self, sandbox: SandboxRef, http_timeout: float | None = None) -> DesktopProcessStatusList: + """ + Get the status of all desktop processes (xvfb, xfce4, x11vnc, novnc). + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopProcessStatusListDict, self._request_json("GET", sandbox, "/api/status", http_timeout=http_timeout)) + 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. + + Args: + sandbox: Sandbox ID or object. + process_name: Desktop process name. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopProcessStatusDict, self._request_json("GET", sandbox, f"/api/process/{process_name}/status")) + return DesktopProcessStatus.from_dict(data) + + @intercept_errors("Failed to restart process: ") + def restart_process(self, sandbox: SandboxRef, process_name: str, http_timeout: float | None = None) -> DesktopProcessRestart: + """ + Restart a desktop process. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopProcessRestartDict, self._request_json("POST", sandbox, f"/api/process/{process_name}/restart", http_timeout=http_timeout)) + 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. + + Args: + sandbox: Sandbox ID or object. + process_name: Desktop process name. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopProcessLogsDict, self._request_json("GET", sandbox, f"/api/process/{process_name}/logs")) + return DesktopProcessLogs.from_dict(data) + + @intercept_errors("Failed to get process errors: ") + def process_errors(self, sandbox: SandboxRef, process_name: str, http_timeout: float | None = None) -> DesktopProcessErrors: + """ + Get stderr logs for a desktop process. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = cast(DesktopProcessErrorsDict, self._request_json("GET", sandbox, f"/api/process/{process_name}/errors", http_timeout=http_timeout)) + return DesktopProcessErrors.from_dict(data) + + @intercept_errors("Failed to stream status: ") + def status_stream(self, sandbox: SandboxRef, *, deadline: float | None = None, http_timeout: 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. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Yields: + object: Items yielded by this operation. + """ + url = f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}/api/status/stream" + response = self._transport.stream("GET", url, timeout=http_timeout) + try: + for event in iter_sse_events(response.iter_lines()): + 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() + + def wait_until_ready(self, sandbox: SandboxRef, *, timeout: float = 60.0, http_timeout: float | None = None) -> 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). + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Raises: + Leap0TimeoutError: If the desktop does not become ready within + *timeout* seconds. + """ + + def _is_transient_leap0(exc: BaseException) -> bool: + """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(_is_transient_leap0), + reraise=True, + ) + def _poll() -> None: + 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}" + ) from exc + except Exception as exc: + raise Leap0TimeoutError( + f"Desktop did not become ready within {timeout:.0f}s" + ) from exc diff --git a/leap0/_sync/filesystem.py b/leap0/_sync/filesystem.py new file mode 100644 index 0000000..a1fd0a2 --- /dev/null +++ b/leap0/_sync/filesystem.py @@ -0,0 +1,521 @@ +from __future__ import annotations + +from typing import Any, cast + +from .._internal.types import JsonObject +from ._transport import Transport +from .._utils.errors import intercept_errors +from ..models.filesystem import EditFileResult, EditResult, FileEdit, FileInfo, LsResult, SearchMatch, TreeResult +from .._schemas.filesystem import EditFileResponseDict, EditFilesResponseDict, ExistsResponseDict, FileInfoDict, GlobResponseDict, GrepResponseDict, LsResponseDict, TreeResponseDict +from ..models.sandbox import SandboxRef, sandbox_id_of + + +class FilesystemClient: + """List, read, write, move, copy, delete, and search files inside a sandbox. + + Text helpers such as :meth:`write_file` and :meth:`read_file` provide the + most ergonomic default API. Use the ``*_bytes`` variants when you need raw + binary access or want to avoid text decoding. + + Attributes: + None. + """ + + 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. + + Args: + sandbox: Sandbox ID or object. + path: Directory path to list. + recursive: List recursively. + exclude: Glob patterns to exclude. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"path": path, "recursive": recursive} + if exclude is not None: + payload["exclude"] = exclude + data = cast(LsResponseDict, self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/ls", json=payload)) + 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. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + + Returns: + object: Result returned by this operation. + """ + data = cast(FileInfoDict, self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/stat", json={"path": path})) + 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, http_timeout: float | None = None) -> None: + """Create a directory. Set *recursive* to create parent directories. + + Args: + sandbox: Sandbox ID or object. + path: Directory path to create. + recursive: Create parents as needed. + permissions: Octal permission string (e.g. ``"755"``). + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + payload: JsonObject = {"path": path, "recursive": recursive} + if permissions is not None: + payload["permissions"] = permissions + self._transport.request( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/mkdir", + json=payload, + expected_status=204, + timeout=http_timeout, + ) + + @intercept_errors("Failed to write file: ") + def write_bytes(self, sandbox: SandboxRef, *, path: str, content: bytes, permissions: str | None = None, http_timeout: float | None = None) -> None: + """Write raw bytes to a single file path. + + Args: + sandbox: Sandbox ID or object. + path: Destination file path. + content: Raw file bytes. + permissions: Optional octal permission string. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Example: + ```python + client.filesystem.write_bytes( + sandbox, + path="/workspace/logo.png", + content=image_bytes, + ) + ``` + """ + params: dict[str, str] = {"path": path} + if permissions is not None: + params["permissions"] = permissions + self._transport.request( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/write-file", + params=params, + content=content, + headers={"Content-Type": "application/octet-stream"}, + expected_status=204, + timeout=http_timeout, + ) + + @intercept_errors("Failed to write file: ") + def write_file(self, sandbox: SandboxRef, *, path: str, content: str, encoding: str = "utf-8", permissions: str | None = None, http_timeout: float | None = None) -> None: + """Write text to a single file path. + + Args: + sandbox: Sandbox ID or object. + path: Destination file path. + content: Text content to write. + encoding: Text encoding used before upload. + permissions: Optional octal permission string. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Example: + ```python + client.filesystem.write_file( + sandbox, + path="/workspace/app.py", + content="print('hello')\n", + ) + ``` + """ + self.write_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], http_timeout: float | None = None) -> None: + """Write multiple files in a single request. + + Args: + sandbox: Sandbox ID or object. + files: Mapping of file path to raw bytes content. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Example: + ```python + client.filesystem.write_files_bytes( + sandbox, + files={"/workspace/a.bin": b"a", "/workspace/b.bin": b"b"}, + ) + ``` + """ + self._transport.request( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/write-files", + files=[(path, data) for path, data in files.items()], + expected_status=204, + timeout=http_timeout, + ) + + @intercept_errors("Failed to write files: ") + def write_files(self, sandbox: SandboxRef, *, files: dict[str, str], encoding: str = "utf-8", http_timeout: float | None = None) -> None: + """Write multiple text files in a single request. + + Args: + sandbox: Sandbox ID or object. + files: Mapping of file path to text content. + encoding: Text encoding used before upload. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + self.write_files_bytes(sandbox, files={p: c.encode(encoding) for p, c in files.items()}) + + @intercept_errors("Failed to read file: ") + def read_bytes( + self, + sandbox: SandboxRef, + *, + path: str, + offset: int | None = None, + limit: int | None = None, + head: int | None = None, + tail: int | None = None, + http_timeout: float | None = None, + ) -> bytes: + """Read a single file and return its raw bytes. + + Args: + sandbox: Sandbox ID or object. + path: Path to the file. + offset: Byte offset to start from. + limit: Maximum bytes to read. + head: Return only the first N lines (mutually exclusive with *tail*). + tail: Return only the last N lines (mutually exclusive with *head*). + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + sandbox: Sandbox ID or object. + path: Path to the file. + offset: Byte offset to start from. + limit: Maximum bytes to read. + head: Return only the first N lines. + tail: Return only the last N lines. + encoding: Text encoding used to decode the response body. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Returns: + str: Decoded file contents. + + Example: + ```python + content = sandbox.filesystem.read_file(path="/workspace/README.md") + print(content) + ``` + + Returns: + bytes: File contents as raw bytes. + """ + 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 + response = self._transport.request("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/read-file", json=payload, timeout=http_timeout) + return response.content + + @intercept_errors("Failed to read file: ") + def read_file( + self, + sandbox: SandboxRef, + *, + path: str, + offset: int | None = None, + limit: int | None = None, + head: int | None = None, + tail: int | None = None, + encoding: str = "utf-8", + http_timeout: float | None = None, + ) -> str: + """Read a single file and return its content decoded as text. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + offset: Parameter for this operation. + limit: Parameter for this operation. + head: Parameter for this operation. + tail: Parameter for this operation. + encoding: Parameter for this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + return self.read_bytes( + sandbox, + path=path, + offset=offset, + limit=limit, + head=head, + 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 and return raw bytes keyed by path. + + Args: + sandbox: Sandbox ID or object. + paths: Parameter for this operation. + + Returns: + object: Result returned by this operation. + """ + 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(self, sandbox: SandboxRef, *, paths: list[str], encoding: str = "utf-8", http_timeout: float | None = None) -> dict[str, str]: + """ + Read multiple files and return decoded text keyed by path. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + 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, http_timeout: float | None = None) -> None: + """Delete a file or directory. + + Args: + sandbox: Sandbox ID or object. + path: Path to delete. + recursive: Required when deleting a non-empty directory. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + self._transport.request( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/delete", + json={"path": path, "recursive": recursive}, + expected_status=204, + timeout=http_timeout, + ) + + @intercept_errors("Failed to set permissions: ") + def set_permissions( + self, + sandbox: SandboxRef, + *, + path: str, + mode: str | None = None, + owner: str | None = None, + group: str | None = None, + http_timeout: float | None = None, + ) -> None: + """ + Set file mode and optionally change owner and group. + + 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 + 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: ") + def glob(self, sandbox: SandboxRef, *, path: str, pattern: str, exclude: list[str] | None = None, http_timeout: float | None = None) -> list[str]: + """Find file paths matching a glob pattern. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + list[str]: Matching file paths. + """ + payload: JsonObject = {"path": path, "pattern": pattern} + if exclude is not None: + payload["exclude"] = exclude + data = cast(GlobResponseDict, self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/glob", json=payload, timeout=http_timeout)) + 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, http_timeout: float | None = None) -> list[SearchMatch]: + """Search for a text pattern across files in a directory. + + Args: + sandbox: Sandbox ID or object. + path: Base directory to search from. + pattern: Text pattern to search for. + include: File pattern filter (e.g. ``"*.py"``). + exclude: Glob patterns to exclude. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + list[SearchMatch]: Matching lines with file and line metadata. + """ + payload: JsonObject = {"path": path, "pattern": pattern} + if include is not None: + payload["include"] = include + if exclude is not None: + payload["exclude"] = exclude + data = cast(GrepResponseDict, self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/grep", json=payload, timeout=http_timeout)) + 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], http_timeout: float | None = None) -> EditFileResult: + """Apply one or more find-and-replace edits to a single file. + + Returns: + EditFileResult: Unified diff and replacement count. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + edits: Parameter for this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + data = cast(EditFileResponseDict, self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/edit-file", + json={"path": path, "edits": [e.to_dict() for e in edits]}, + timeout=http_timeout, + )) + return EditFileResult.from_dict(data) + + @intercept_errors("Failed to edit files: ") + def edit_files(self, sandbox: SandboxRef, *, paths: list[str], find: str, replace: str = "", http_timeout: float | None = None) -> list[EditResult]: + """Replace text across multiple files at once. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + list[EditResult]: Per-file edit results. + """ + data = cast(EditFilesResponseDict, self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/edit-files", + json={"files": paths, "find": find, "replace": replace}, + timeout=http_timeout, + )) + 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. + + Args: + sandbox: Sandbox ID or object. + src_path: Parameter for this operation. + dst_path: Parameter for this operation. + overwrite: Parameter for this operation. + """ + self._transport.request( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/move", + json={"src_path": src_path, "dst_path": dst_path, "overwrite": overwrite}, + expected_status=204, + ) + + @intercept_errors("Failed to copy: ") + def copy( + self, + sandbox: SandboxRef, + *, + src_path: str, + dst_path: str, + recursive: bool = False, + overwrite: bool | None = None, + http_timeout: float | None = None, + ) -> None: + """Copy a file or directory. + + Args: + sandbox: Sandbox ID or object. + src_path: Source path. + dst_path: Destination path. + recursive: Required when copying a directory. + overwrite: Overwrite the destination if it already exists. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + payload: JsonObject = {"src_path": src_path, "dst_path": dst_path, "recursive": recursive} + if overwrite is not None: + payload["overwrite"] = overwrite + self._transport.request("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/copy", json=payload, expected_status=204, timeout=http_timeout) + + @intercept_errors("Failed to check path: ") + def exists(self, sandbox: SandboxRef, *, path: str, http_timeout: float | None = None) -> bool: + """Check whether a path exists in the sandbox. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + bool: ``True`` when the path exists. + """ + data = cast(ExistsResponseDict, self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/exists", json={"path": path}, timeout=http_timeout)) + 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, http_timeout: float | None = None) -> TreeResult: + """Get a recursive directory tree. + + Args: + sandbox: Sandbox ID or object. + path: Root directory path. + max_depth: Maximum traversal depth. + exclude: Glob patterns to exclude. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Returns: + TreeResult: Recursive directory tree rooted at ``path``. + """ + payload: JsonObject = {"path": path} + if max_depth is not None: + payload["max_depth"] = max_depth + if exclude is not None: + payload["exclude"] = exclude + data = cast(TreeResponseDict, self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/tree", json=payload, timeout=http_timeout)) + return TreeResult.from_dict(data) + + +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(): + body_preview = body[:200] if len(body) > 200 else body + raise ValueError( + f"Expected multipart response but got content_type={content_type!r} " + f"(body length={len(body)}, preview={body_preview!r})" + ) + 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 diff --git a/leap0/git.py b/leap0/_sync/git.py similarity index 53% rename from leap0/git.py rename to leap0/_sync/git.py index 4c5fd28..59c500e 100644 --- a/leap0/git.py +++ b/leap0/_sync/git.py @@ -1,23 +1,31 @@ from __future__ import annotations -from typing import Any +from typing import cast +from .._internal.types import JsonObject from ._transport import Transport -from ._utils.errors import intercept_errors -from .common.git import GitCommitResponseDict, GitCommitResult, GitResult, GitResultDict -from .common.sandbox import SandboxRef, sandbox_id_of +from .._utils.errors import intercept_errors +from ..models.git import GitCommitResult, GitResult +from .._schemas.git import GitCommitResponseDict, GitResultDict +from ..models.sandbox import SandboxRef, sandbox_id_of class GitClient: """Clone repositories, inspect diffs and history, manage branches, stage - files, commit, push, and pull inside a running sandbox. + files, commit, push, and pull inside a running sandbox. + + This client is useful when you want to automate Git workflows without + shelling out manually through :class:`ProcessClient`. + + Attributes: + None. """ def __init__(self, transport: Transport): self._transport = transport - def _git_result(self, path: str, payload: dict[str, Any]) -> GitResult: - data: GitResultDict = self._transport.request_json("POST", path, json=payload) # type: ignore[assignment] + def _git_result(self, path: str, payload: JsonObject, http_timeout: float | None = None) -> GitResult: + data = cast(GitResultDict, self._transport.request_json("POST", path, json=payload, timeout=http_timeout)) return GitResult.from_dict(data) @intercept_errors("Failed to clone repository: ") @@ -32,6 +40,7 @@ def clone( depth: int | None = None, username: str | None = None, password: str | None = None, + http_timeout: float | None = None, ) -> GitResult: """Clone a remote repository into the sandbox. @@ -44,8 +53,16 @@ def clone( depth: Shallow clone depth. username: Auth username (for private repos). password: Auth password or token (for private repos). + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + GitResult: Command output and exit status from the clone operation. """ - payload: dict[str, Any] = {"url": url, "path": path} + payload: JsonObject = {"url": url, "path": path} if branch is not None: payload["branch"] = branch if commit_id is not None: @@ -56,12 +73,21 @@ def clone( payload["username"] = username if password is not None: payload["password"] = password - return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/clone", payload) + return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/clone", payload, http_timeout=http_timeout) @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}) + def status(self, sandbox: SandboxRef, *, path: str, http_timeout: float | None = None) -> GitResult: + """Get the current repository status. + + Returns: + GitResult: Git status output and exit status. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/status", {"path": path}, http_timeout=http_timeout) @intercept_errors("Failed to list branches: ") def branches( @@ -72,50 +98,87 @@ def branches( branch_type: str = "local", contains: str | None = None, not_contains: str | None = None, + http_timeout: float | None = None, ) -> GitResult: """List branches in the repository. - - Args: - sandbox: Sandbox ID or object. - path: Path to the git repo. - branch_type: Filter by ``"local"``, ``"remote"``, or ``"all"``. - contains: Only branches containing this commit SHA. - not_contains: Exclude branches containing this commit SHA. + + Args: + sandbox: Sandbox ID or object. + path: Path to the git repo. + branch_type: Filter by ``"local"``, ``"remote"``, or ``"all"``. + contains: Only branches containing this commit SHA. + not_contains: Exclude branches containing this commit SHA. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. """ - payload: dict[str, Any] = {"path": path, "branch_type": branch_type} + payload: JsonObject = {"path": path, "branch_type": branch_type} if contains is not None: payload["contains"] = contains if not_contains is not None: payload["not_contains"] = not_contains - return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/branches", payload) + return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/branches", payload, http_timeout=http_timeout) @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} + def diff_unstaged(self, sandbox: SandboxRef, *, path: str, context_lines: int | None = None, http_timeout: float | None = None) -> GitResult: + """ + Show working tree changes that are not staged yet. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"path": path} if context_lines is not None: 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} + def diff_staged(self, sandbox: SandboxRef, *, path: str, context_lines: int | None = None, http_timeout: float | None = None) -> GitResult: + """ + Show changes that are already staged for the next commit. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"path": path} if context_lines is not None: 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} + def diff(self, sandbox: SandboxRef, *, path: str, target: str, context_lines: int | None = None, http_timeout: float | None = None) -> GitResult: + """ + Compare the current state against a branch, tag, or commit. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"path": path, "target": target} if context_lines is not None: 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.""" + """Unstage all currently staged changes. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + + Returns: + object: Result returned by this operation. + """ return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/reset", {"path": path}) @intercept_errors("Failed to get git log: ") @@ -127,17 +190,22 @@ def log( max_count: int | None = None, start_timestamp: str | None = None, end_timestamp: str | None = None, + http_timeout: float | None = None, ) -> GitResult: """Show commit history with optional limits and date filters. - - Args: - sandbox: Sandbox ID or object. - path: Path to the git repo. - max_count: Maximum number of commits to return (default 10). - start_timestamp: Start timestamp filter. - end_timestamp: End timestamp filter. + + Args: + sandbox: Sandbox ID or object. + path: Path to the git repo. + max_count: Maximum number of commits to return (default 10). + start_timestamp: Start timestamp filter. + end_timestamp: End timestamp filter. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. """ - payload: dict[str, Any] = {"path": path} + payload: JsonObject = {"path": path} if max_count is not None: payload["max_count"] = max_count if start_timestamp is not None: @@ -148,7 +216,16 @@ def log( @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.""" + """Show the full output for a commit, branch, or tag revision. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + revision: Parameter for this operation. + + Returns: + object: Result returned by this operation. + """ return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/show", {"path": path, "revision": revision}) @intercept_errors("Failed to create branch: ") @@ -160,17 +237,22 @@ def create_branch( name: str, checkout: bool | None = None, base_branch: str | None = None, + http_timeout: float | None = None, ) -> GitResult: """Create a new branch. - - Args: - sandbox: Sandbox ID or object. - path: Path to the git repo. - name: New branch name. - checkout: Switch to the new branch immediately. - base_branch: Branch from a specific revision. + + Args: + sandbox: Sandbox ID or object. + path: Path to the git repo. + name: New branch name. + checkout: Switch to the new branch immediately. + base_branch: Branch from a specific revision. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. """ - payload: dict[str, Any] = {"path": path, "name": name} + payload: JsonObject = {"path": path, "name": name} if checkout is not None: payload["checkout"] = checkout if base_branch is not None: @@ -178,21 +260,47 @@ def create_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} + def checkout_branch(self, sandbox: SandboxRef, *, path: str, branch: str, create: bool | None = None, http_timeout: float | None = None) -> GitResult: + """ + Switch to an existing branch. Set *create* to create it if it does not exist. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload: JsonObject = {"path": path, "branch": branch} if create is not None: 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.""" + """Delete a branch. Set *force* to delete even if unmerged. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + name: Name used by this operation. + force: Parameter for this operation. + + Returns: + object: Result returned by this operation. + """ 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.""" + def add(self, sandbox: SandboxRef, *, path: str, files: list[str], http_timeout: float | None = None) -> GitResult: + """ + Stage files for the next commit. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/add", {"path": path, "files": files}) @intercept_errors("Failed to commit: ") @@ -205,6 +313,7 @@ def commit( author: str | None = None, email: str | None = None, allow_empty: bool | None = None, + http_timeout: float | None = None, ) -> GitCommitResult: """Create a commit from staged changes. @@ -215,19 +324,35 @@ def commit( author: Author name. email: Author email. allow_empty: Allow creating an empty commit. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + sandbox: Sandbox ID or object. + path: Path to the git repo. + remote: Remote name (default ``"origin"``). + branch: Branch name. + set_upstream: Set upstream tracking. + username: Auth username. + password: Auth password or token. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + GitCommitResult: Commit result including commit ID when successful. """ - payload: dict[str, Any] = {"path": path, "message": message} + payload: JsonObject = {"path": path, "message": message} if author is not None: payload["author"] = author if email is not None: payload["email"] = email if allow_empty is not None: payload["allow_empty"] = allow_empty - data: GitCommitResponseDict = self._transport.request_json( # type: ignore[assignment] + data = cast(GitCommitResponseDict, self._transport.request_json( "POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/commit", json=payload, - ) + timeout=http_timeout, + )) return GitCommitResult.from_dict(data) @intercept_errors("Failed to push: ") @@ -241,19 +366,24 @@ def push( set_upstream: bool | None = None, username: str | None = None, password: str | None = None, + http_timeout: float | None = None, ) -> GitResult: """Push commits to a remote. - + Args: sandbox: Sandbox ID or object. - path: Path to the git repo. - remote: Remote name (default ``"origin"``). - branch: Branch name. - set_upstream: Set upstream tracking. - username: Auth username. - password: Auth password or token. + path: Path used by this operation. + remote: Parameter for this operation. + branch: Parameter for this operation. + set_upstream: Parameter for this operation. + username: Parameter for this operation. + password: Parameter for this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. """ - payload: dict[str, Any] = {"path": path} + payload: JsonObject = {"path": path} if remote is not None: payload["remote"] = remote if branch is not None: @@ -278,20 +408,25 @@ def pull( set_upstream: bool | None = None, username: str | None = None, password: str | None = None, + http_timeout: float | None = None, ) -> GitResult: """Pull commits from a remote. Set *rebase* to rebase instead of merge. - - Args: - sandbox: Sandbox ID or object. - path: Path to the git repo. - 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. + + Args: + sandbox: Sandbox ID or object. + path: Path to the git repo. + 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. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. """ - payload: dict[str, Any] = {"path": path} + payload: JsonObject = {"path": path} if remote is not None: payload["remote"] = remote if branch is not None: diff --git a/leap0/lsp.py b/leap0/_sync/lsp.py similarity index 50% rename from leap0/lsp.py rename to leap0/_sync/lsp.py index c2e440d..15c0b6c 100644 --- a/leap0/lsp.py +++ b/leap0/_sync/lsp.py @@ -1,20 +1,28 @@ from __future__ import annotations -from typing import Any +from typing import cast +from .._internal.types import JsonObject from ._transport import Transport -from ._utils.errors import intercept_errors -from ._utils.url import file_uri as _file_uri -from .common.lsp import LspJsonRpcResponse, LspJsonRpcResponseDict, LspResponse, LspSuccessResponseDict -from .common.sandbox import SandboxRef, sandbox_id_of +from .._utils.errors import intercept_errors +from .._utils.url import file_uri as _file_uri +from ..models.lsp import LspJsonRpcResponse, LspResponse +from .._schemas.lsp import LspJsonRpcResponseDict, LspSuccessResponseDict +from ..models.sandbox import SandboxRef, sandbox_id_of class LspClient: """Start and interact with language servers for code intelligence inside - a sandbox. - - Supported languages: Python (pyright) and TypeScript/JavaScript - (typescript-language-server). + a sandbox. + + Supported languages: Python (pyright) and TypeScript/JavaScript + (typescript-language-server). + + The typical flow is ``start`` -> ``did_open`` -> ``completions`` or + ``document_symbols`` -> ``did_close`` -> ``stop``. + + Attributes: + None. """ def __init__(self, transport: Transport): @@ -31,14 +39,35 @@ def start(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str) sandbox: Sandbox ID or object. language_id: Language identifier (``"python"``, ``"typescript"``, or ``"javascript"``). path_to_project: Project directory path inside the sandbox. + + Args: + sandbox: Sandbox ID or object. + language_id: Language identifier. + path_to_project: Project directory path. + uri: Document URI (e.g. ``"file:///home/user/project/main.py"``). + text: Full document text. + version: Document version number. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + LspResponse: Server startup result. """ - 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] + data = cast(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})) 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] + """Send ``shutdown`` and ``exit`` to the language server and terminate it. + + Args: + sandbox: Sandbox ID or object. + language_id: Language identifier for the LSP operation. + path_to_project: Project path inside the sandbox. + + Returns: + object: Result returned by this operation. + """ + data = cast(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})) return LspResponse.from_dict(data) @intercept_errors("Failed to open document: ") @@ -51,20 +80,22 @@ def did_open( uri: str, text: str | None = None, version: int = 1, + http_timeout: float | None = None, ) -> None: """Notify the language server that a document was opened. - - Must be called before requesting completions or symbols. - + + Must be called before requesting completions or symbols. + Args: sandbox: Sandbox ID or object. - language_id: Language identifier. - path_to_project: Project directory path. - uri: Document URI (e.g. ``"file:///home/user/project/main.py"``). - text: Full document text. - version: Document version number. + language_id: Language identifier for the LSP operation. + path_to_project: Project path inside the sandbox. + uri: Document URI. + text: Parameter for this operation. + version: Parameter for this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. """ - payload: dict[str, Any] = { + payload: JsonObject = { "language_id": language_id, "path_to_project": path_to_project, "uri": uri, @@ -77,6 +108,7 @@ def did_open( f"/v1/sandbox/{sandbox_id_of(sandbox)}/lsp/did-open", json=payload, expected_status=204, + timeout=http_timeout, ) @intercept_errors("Failed to open document: ") @@ -89,13 +121,26 @@ def did_open_path( path: str, text: str | None = None, version: int = 1, + http_timeout: float | None = None, ) -> None: - """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) + """ + Like :meth:`did_open` but accepts a file path instead of a URI. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + self.did_open(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path), text=text, version=version, http_timeout=http_timeout) @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.""" + """Notify the language server that a document was closed. + + Args: + sandbox: Sandbox ID or object. + language_id: Language identifier for the LSP operation. + path_to_project: Project path inside the sandbox. + uri: Document URI. + """ self._transport.request( "POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/lsp/did-close", @@ -104,9 +149,14 @@ def did_close(self, sandbox: SandboxRef, *, language_id: str, path_to_project: s ) @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)) + def did_close_path(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str, path: str, http_timeout: float | None = None) -> None: + """ + Like :meth:`did_close` but accepts a file path instead of a URI. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + self.did_close(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path), http_timeout=http_timeout) @intercept_errors("Failed to get completions: ") def completions( @@ -118,9 +168,17 @@ def completions( uri: str, line: int, character: int, + http_timeout: float | None = None, ) -> LspJsonRpcResponse: - """Returns the JSON-RPC 2.0 response from the language server.""" - data: LspJsonRpcResponseDict = self._transport.request_json( # type: ignore[assignment] + """Request completions from the language server. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + LspJsonRpcResponse: Raw JSON-RPC response payload. + """ + data = cast(LspJsonRpcResponseDict, self._transport.request_json( "POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/lsp/completions", json={ @@ -129,7 +187,8 @@ def completions( "uri": uri, "position": {"line": line, "character": character}, }, - ) + timeout=http_timeout, + )) return LspJsonRpcResponse.from_dict(data) @intercept_errors("Failed to get completions: ") @@ -142,21 +201,46 @@ def completions_path( path: str, line: int, character: int, + http_timeout: float | None = None, ) -> 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) + """ + Like :meth:`completions` but accepts a file path instead of a URI. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + return self.completions(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path), line=line, character=character, http_timeout=http_timeout) @intercept_errors("Failed to get document symbols: ") - 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] + def document_symbols(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str, uri: str, http_timeout: float | None = None) -> LspJsonRpcResponse: + """Request document symbols from the language server. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + LspJsonRpcResponse: Raw JSON-RPC response payload. + """ + data = cast(LspJsonRpcResponseDict, self._transport.request_json( "POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/lsp/document-symbols", json={"language_id": language_id, "path_to_project": path_to_project, "uri": uri}, - ) + timeout=http_timeout, + )) 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) -> 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)) + def document_symbols_path(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str, path: str, http_timeout: float | None = None) -> LspJsonRpcResponse: + """ + Like :meth:`document_symbols` but accepts a file path instead of a URI. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + return self.document_symbols(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path), http_timeout=http_timeout) diff --git a/leap0/_sync/process.py b/leap0/_sync/process.py new file mode 100644 index 0000000..038f9af --- /dev/null +++ b/leap0/_sync/process.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import Any, cast + +from .._internal.types import JsonObject +from ._transport import Transport +from .._utils.errors import intercept_errors +from ..models.process import ProcessResult +from .._schemas.process import ProcessResultDict +from ..models.sandbox import SandboxRef, sandbox_id_of + + +class ProcessClient: + """Execute one-shot shell commands inside a running sandbox. + + Use this client for non-interactive command execution where you want the + full result back as a single response. + + Attributes: + None. + """ + + 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. + + The command runs inside ``/bin/sh -c``. + + Args: + sandbox: Sandbox ID or object. + command: Shell command to execute. + cwd: Working directory. + timeout: Timeout in seconds (default 30). + + Returns: + ProcessResult: Command result including exit code, stdout, and stderr. + + Example: + ```python + result = client.process.execute( + sandbox, + command="ls -la /workspace", + ) + print(result.stdout) + ``` + """ + payload: JsonObject = {"command": command} + if cwd is not None: + payload["cwd"] = cwd + if timeout is not None: + payload["timeout"] = timeout + data = cast(ProcessResultDict, self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/process/execute", json=payload)) + return ProcessResult.from_dict(data) diff --git a/leap0/_sync/pty.py b/leap0/_sync/pty.py new file mode 100644 index 0000000..1dccace --- /dev/null +++ b/leap0/_sync/pty.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from typing import Any, cast + +from websockets.sync.client import connect + +from .._internal.types import JsonObject +from ._transport import Transport +from .._utils.errors import intercept_errors +from .._utils.url import websocket_url_from_http +from ..models.pty import CreatePtySessionParams, PtyConnection, PtySession +from .._schemas.pty import PtyListResponseDict, PtySessionInfoDict +from ..models.sandbox import SandboxRef, sandbox_id_of + + +class PtyClient: + """Create and manage interactive terminal sessions inside a sandbox. + + Connect via WebSocket for real-time bidirectional I/O, similar to SSH or + a browser-based terminal. + + Example: + ```python + sandbox = client.sandboxes.create() + session = sandbox.pty.create(cols=120, rows=30) + conn = sandbox.pty.connect(session.id) + conn.send("pwd + ") + print(conn.recv().decode()) + conn.close() + ``` + + Attributes: + None. + """ + + 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. + + Args: + sandbox: Sandbox ID or object. + + Returns: + object: Result returned by this operation. + """ + data = cast(PtyListResponseDict, self._transport.request_json("GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty")) + return [PtySession.from_dict(item) for item in data.get("items", [])] + + @intercept_errors("Failed to create PTY session: ") + def create( + self, + sandbox: SandboxRef, + *, + session_id: str | None = None, + cols: int | None = None, + rows: int | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + lazy_start: bool | None = None, + http_timeout: float | None = None, + ) -> PtySession: + """Create a terminal session with a shell process. + + Args: + sandbox: Sandbox ID or object. + session_id: Session ID. Auto-generated if omitted. + cols: Terminal columns. + rows: Terminal rows. + cwd: Starting directory. + envs: Environment variables. + lazy_start: Defer shell start until the first WebSocket connection. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + sandbox: Sandbox ID or object. + session_id: PTY session identifier. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + PtySession: Metadata for the created PTY session. + """ + payload = CreatePtySessionParams( + session_id=session_id, + cols=cols, + rows=rows, + cwd=cwd, + envs=envs, + lazy_start=lazy_start, + ).to_payload() + data = cast(PtySessionInfoDict, self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty", json=payload, expected_status=201, timeout=http_timeout)) + 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. + + Args: + sandbox: Sandbox ID or object. + session_id: PTY session identifier. + + Returns: + object: Result returned by this operation. + """ + data = cast(PtySessionInfoDict, self._transport.request_json("GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}")) + return PtySession.from_dict(data) + + @intercept_errors("Failed to delete PTY session: ") + def delete(self, sandbox: SandboxRef, session_id: str, http_timeout: float | None = None) -> None: + """Kill the shell process and remove the session. + + Args: + sandbox: Sandbox ID or object. + session_id: PTY session identifier. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + self._transport.request("DELETE", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}", expected_status=204, timeout=http_timeout) + + @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. + + Args: + sandbox: Sandbox ID or object. + session_id: PTY session identifier. + cols: Parameter for this operation. + rows: Parameter for this operation. + + Returns: + object: Result returned by this operation. + """ + data = cast(PtySessionInfoDict, self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}/resize", json={"cols": cols, "rows": rows})) + return PtySession.from_dict(data) + + def websocket_url(self, sandbox: SandboxRef, session_id: str) -> str: + """Build the WSS URL for connecting to a PTY session. + + Args: + sandbox: Sandbox ID or object. + session_id: PTY session identifier. + + Returns: + object: Result returned by this operation. + """ + 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, http_timeout: float | None = None, **kwargs: Any) -> PtyConnection: + """Open a WebSocket connection for interactive terminal I/O. + + Returns a :class:`PtyConnection` with ``send``, ``recv``, and + ``close`` methods. All messages are binary frames containing raw + terminal bytes. + + Args: + sandbox: Sandbox ID or object. + session_id: PTY session identifier. + http_timeout: Optional WebSocket open timeout in seconds. + **kwargs: Additional keyword arguments passed to ``websockets.sync.client.connect``. + + Returns: + PtyConnection: Open WebSocket-backed PTY connection. + """ + url = self.websocket_url(sandbox, session_id) + if http_timeout is not None and "open_timeout" not in kwargs: + kwargs["open_timeout"] = http_timeout + websocket = connect(url, additional_headers={self._transport.auth_header: self._transport.auth_value}, **kwargs) + return PtyConnection(websocket=websocket) diff --git a/leap0/_sync/sandbox.py b/leap0/_sync/sandbox.py new file mode 100644 index 0000000..951f5d7 --- /dev/null +++ b/leap0/_sync/sandbox.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +import os +from functools import wraps +from typing import Generic, Protocol, TypeVar, cast + +from ._transport import Transport +from .._utils.errors import intercept_errors +from .._utils.url import ensure_leading_slash, sandbox_base_url, websocket_url_from_http +from .._internal.types import SandboxFactory +from ..models.config import DEFAULT_MEMORY_MIB, DEFAULT_TEMPLATE_NAME, DEFAULT_TIMEOUT_MIN, DEFAULT_VCPU +from ..models.sandbox import CreateSandboxParams, Sandbox as SandboxData, SandboxRef, SandboxStatus, sandbox_id_of +from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict, SandboxStatusResponseDict + + +_OTEL_ENV_KEYS = ( + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", +) + +SandboxT = TypeVar("SandboxT", SandboxData, SandboxStatus, "Sandbox") + + +class _BoundSandboxCallable(Protocol): + def __call__(self, sandbox: object, *args: object, **kwargs: object) -> object: ... + + +class _SandboxServiceProxy: + """Bind a service client to a specific sandbox instance.""" + + def __init__(self, service: object, sandbox: Sandbox): + self._service = service + self._sandbox = sandbox + + def __getattr__(self, name: str) -> object: + attr = getattr(self._service, name) + if not callable(attr): + return attr + + bound_attr = cast(_BoundSandboxCallable, attr) + + @wraps(attr) + def bound(*args: object, **kwargs: object) -> object: + return bound_attr(self._sandbox, *args, **kwargs) + + return bound + + +class Sandbox: + """Sandbox object with bound service clients. + + This object exposes sandbox metadata directly and provides bound service + handles so you can call methods like ``sandbox.filesystem.read_file(...)`` + instead of passing the sandbox object into every client call. + + Attributes: + filesystem: Bound filesystem client. + fs: Alias for ``filesystem``. + git: Bound git client. + process: Bound process client. + pty: Bound PTY client. + lsp: Bound LSP client. + ssh: Bound SSH client. + code_interpreter: Bound code interpreter client. + desktop: Bound desktop client. + """ + + def __init__(self, client: object, data: SandboxData | SandboxStatus): + self._client = client + self._data: SandboxData | SandboxStatus = data + self.filesystem = _SandboxServiceProxy(client.filesystem, self) + self.fs = self.filesystem + self.git = _SandboxServiceProxy(client.git, self) + self.process = _SandboxServiceProxy(client.process, self) + self.pty = _SandboxServiceProxy(client.pty, self) + self.lsp = _SandboxServiceProxy(client.lsp, self) + self.ssh = _SandboxServiceProxy(client.ssh, self) + self.code_interpreter = _SandboxServiceProxy(client.code_interpreter, self) + self.desktop = _SandboxServiceProxy(client.desktop, self) + + def __getattr__(self, name: str) -> object: + return getattr(self._data, name) + + def __repr__(self) -> str: + state = getattr(self._data, "state", None) + return f"Sandbox(id={self.id!r}, state={state!r})" + + def refresh(self) -> Sandbox: + """Refresh sandbox metadata in place. + + Returns: + Sandbox: This sandbox object with refreshed metadata. + """ + latest = self._client.sandboxes.get(self.id) + self._data = latest._data + return self + + def pause(self) -> Sandbox: + """Pause the sandbox and update this handle with the latest metadata. + + Returns: + Sandbox: This sandbox object with updated metadata. + """ + latest = self._client.sandboxes.pause(self) + self._data = latest._data + return self + + def delete(self, http_timeout: float | None = None) -> None: + """Delete the sandbox. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + self._client.sandboxes.delete(self, http_timeout=http_timeout) + + def invoke_url(self, path: str = "/", *, port: int | None = None) -> str: + """Build an HTTPS URL that routes directly to this sandbox. + + Args: + path: Request path inside the sandbox application. + port: Port number for the generated URL. + + Returns: + str: Sandbox-scoped HTTPS URL. + """ + return self._client.sandboxes.invoke_url(self, path=path, port=port) + + def websocket_url(self, path: str = "/", *, port: int | None = None) -> str: + """Build a WSS URL that routes directly to this sandbox. + + Args: + path: Request path inside the sandbox application. + port: Port number for the generated URL. + + Returns: + str: Sandbox-scoped websocket URL. + """ + return self._client.sandboxes.websocket_url(self, path=path, port=port) + + +def _inject_otel_env(env_vars: dict[str, str] | None) -> dict[str, str] | None: + otel = {k: v for k in _OTEL_ENV_KEYS if (v := os.environ.get(k))} + if not otel and not env_vars: + return None + if not otel: + return env_vars + merged = dict(otel) + if env_vars: + merged.update(env_vars) + return merged + + +class SandboxesClient(Generic[SandboxT]): + """Create, inspect, pause, and delete sandboxes. + + Sandboxes are isolated execution environments with their own compute, + filesystem, and network boundary. + + Attributes: + None. + """ + + def __init__( + self, + transport: Transport, + *, + sandbox_domain: str | None = None, + sandbox_factory: SandboxFactory[SandboxData | SandboxStatus, SandboxT] | None = None, + ): + self._transport = transport + self._sandbox_domain = sandbox_domain.strip("/") if sandbox_domain else None + self._sandbox_factory = sandbox_factory + + def _wrap_sandbox(self, sandbox: SandboxData | SandboxStatus) -> SandboxT | SandboxData | SandboxStatus: + if self._sandbox_factory is None: + return sandbox + return self._sandbox_factory(sandbox) + + @intercept_errors("Failed to create sandbox: ") + def create( + self, + *, + template_name: str = DEFAULT_TEMPLATE_NAME, + vcpu: int = DEFAULT_VCPU, + memory_mib: int = DEFAULT_MEMORY_MIB, + timeout_min: int = DEFAULT_TIMEOUT_MIN, + auto_pause: bool = False, + telemetry: bool = False, + env_vars: dict[str, str] | None = None, + network_policy: NetworkPolicyDict | None = None, + http_timeout: float | None = None, + ) -> SandboxT | SandboxData | SandboxStatus: + """Create a new sandbox from a template. + + Args: + template_name: Name of the template to use. + vcpu: Number of virtual CPUs (1 to 8). + memory_mib: Memory in MiB (512 to 8192, must be even). + timeout_min: Sandbox timeout in minutes (1 to 480, default 5). + auto_pause: Automatically pause the sandbox into a snapshot on timeout. + telemetry: Enable OpenTelemetry export. Reads ``OTEL_EXPORTER_OTLP_ENDPOINT`` + and ``OTEL_EXPORTER_OTLP_HEADERS`` from the local environment and + injects them into the sandbox. + env_vars: Environment variables to set inside the sandbox. + network_policy: Outbound network policy for the sandbox. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + SandboxT | SandboxData | SandboxStatus: Created sandbox object. + """ + params = CreateSandboxParams( + template_name=template_name, + vcpu=vcpu, + memory_mib=memory_mib, + timeout_min=timeout_min, + auto_pause=auto_pause, + telemetry=telemetry, + env_vars=_inject_otel_env(env_vars) if telemetry else env_vars, + network_policy=network_policy, + ) + payload = params.to_payload() + payload.pop("telemetry", None) + data: SandboxCreateResponseDict = self._transport.request_json( + "POST", "/v1/sandbox", json=payload, expected_status=201, + timeout=http_timeout, + ) + return self._wrap_sandbox(SandboxData.from_dict(data)) + + @intercept_errors("Failed to pause sandbox: ") + def pause(self, sandbox: SandboxRef, http_timeout: float | None = None) -> SandboxT | SandboxData | SandboxStatus: + """Pause a running sandbox. + + Args: + sandbox: Sandbox ID or object. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + SandboxT | SandboxData | SandboxStatus: Updated sandbox object. + """ + data: SandboxCreateResponseDict = self._transport.request_json( + "POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pause", expected_status=201, + timeout=http_timeout, + ) + return self._wrap_sandbox(SandboxData.from_dict(data)) + + @intercept_errors("Failed to get sandbox: ") + def get(self, sandbox: SandboxRef, http_timeout: float | None = None) -> SandboxT | SandboxData | SandboxStatus: + """Get the latest sandbox metadata. + + Args: + sandbox: Sandbox ID or object. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + SandboxT | SandboxData | SandboxStatus: Current sandbox object. + """ + data: SandboxStatusResponseDict = self._transport.request_json( + "GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/", + timeout=http_timeout, + ) + return self._wrap_sandbox(SandboxStatus.from_dict(data)) + + @intercept_errors("Failed to delete sandbox: ") + def delete(self, sandbox: SandboxRef) -> None: + """Terminate and delete a sandbox. + + Args: + sandbox: Sandbox ID or object. + """ + self._transport.request("DELETE", f"/v1/sandbox/{sandbox_id_of(sandbox)}/", expected_status=204) + + def invoke_url(self, sandbox: SandboxRef, path: str = "/", *, port: int | None = None) -> str: + """Build an HTTPS URL that routes directly to the sandbox. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + port: Port number for the generated URL. + + Returns: + object: Result returned by this operation. + """ + return f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain, port=port)}{ensure_leading_slash(path)}" + + def websocket_url(self, sandbox: SandboxRef, path: str = "/", *, port: int | None = None) -> str: + """Build a WSS URL that routes directly to the sandbox. + + Args: + sandbox: Sandbox ID or object. + path: Path used by this operation. + port: Port number for the generated URL. + + Returns: + object: Result returned by this operation. + """ + return websocket_url_from_http(self.invoke_url(sandbox, path, port=port)) + + + + +__all__ = ["Sandbox", "SandboxesClient"] diff --git a/leap0/_sync/snapshots.py b/leap0/_sync/snapshots.py new file mode 100644 index 0000000..4dd2ab2 --- /dev/null +++ b/leap0/_sync/snapshots.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from typing import Generic, TypeVar, cast + +from ._transport import Transport +from .._internal.types import SandboxFactory +from .._utils.errors import intercept_errors +from ..models.sandbox import Sandbox, SandboxRef, sandbox_id_of +from .._schemas.sandbox import SandboxCreateResponseDict +from ..models.snapshot import CreateSnapshotParams, ResumeSnapshotParams, Snapshot, SnapshotRef, snapshot_id_of +from .._schemas.snapshot import SnapshotCreateResponseDict + +SnapshotSandboxT = TypeVar("SnapshotSandboxT") + + +class SnapshotsClient(Generic[SnapshotSandboxT]): + """Create, resume, and delete sandbox snapshots. + + A snapshot captures the full state of a running sandbox so it can be + restored later. + + Use snapshots when you want a reusable checkpoint of an initialized + sandbox environment. + + Attributes: + None. + """ + + def __init__(self, transport: Transport, *, sandbox_factory: SandboxFactory[Sandbox, SnapshotSandboxT] | None = None): + self._transport = transport + self._sandbox_factory = sandbox_factory + + def _wrap_sandbox(self, sandbox: Sandbox) -> SnapshotSandboxT | Sandbox: + if self._sandbox_factory is None: + return sandbox + return self._sandbox_factory(sandbox) + + @intercept_errors("Failed to create snapshot: ") + def create( + self, + sandbox: SandboxRef, + *, + name: str | None = None, + http_timeout: float | None = None, + ) -> Snapshot: + """Create a snapshot of a running sandbox without stopping it. + + Args: + sandbox: Sandbox ID or object to snapshot. + name: Optional snapshot name. Auto-generated if omitted. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + sandbox: Sandbox ID or object to pause. + name: Optional snapshot name. Auto-generated if omitted. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Returns: + Snapshot: Created snapshot metadata. + + Returns: + Snapshot: Snapshot metadata including ID and optional name. + """ + payload = CreateSnapshotParams(name=name).to_payload() + data = cast(SnapshotCreateResponseDict, self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/snapshot/create", + json=payload, + expected_status=201, + timeout=http_timeout, + )) + return Snapshot.from_dict(data) + + @intercept_errors("Failed to pause sandbox: ") + def pause( + self, + sandbox: SandboxRef, + *, + name: str | None = None, + http_timeout: float | None = None, + ) -> Snapshot: + """Pause a running sandbox and create a snapshot in one step. + + The sandbox is stopped after the snapshot is taken. + + Args: + sandbox: Sandbox ID or object. + name: Name used by this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + payload = CreateSnapshotParams(name=name).to_payload() + data = cast(SnapshotCreateResponseDict, self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/snapshot/pause", + json=payload, + expected_status=201, + timeout=http_timeout, + )) + return Snapshot.from_dict(data) + + @intercept_errors("Failed to resume snapshot: ") + def resume( + self, + *, + snapshot_name: str, + auto_pause: bool = False, + timeout_min: int | None = None, + network_policy: NetworkPolicyDict | None = None, + http_timeout: float | None = None, + ) -> SnapshotSandboxT | Sandbox: + """Restore a sandbox from a snapshot. + + Args: + snapshot_name: Name of the snapshot to restore. + auto_pause: Automatically pause the restored sandbox on timeout. + timeout_min: Sandbox timeout in minutes. + network_policy: Override the network policy from the snapshot. + + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Args: + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + Sandbox: Newly resumed sandbox. + """ + payload = ResumeSnapshotParams( + snapshot_name=snapshot_name, + auto_pause=auto_pause, + timeout_min=timeout_min, + network_policy=network_policy, + ).to_payload() + data = cast(SandboxCreateResponseDict, self._transport.request_json( + "POST", + "/v1/snapshot/resume", + json=payload, + expected_status=201, + timeout=http_timeout, + )) + return self._wrap_sandbox(Sandbox.from_dict(data)) + + @intercept_errors("Failed to delete snapshot: ") + def delete(self, snapshot: SnapshotRef, http_timeout: float | None = None) -> None: + """ + Delete a snapshot. + + Args: + snapshot: Parameter for this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + self._transport.request("DELETE", f"/v1/snapshot/{snapshot_id_of(snapshot)}", expected_status=204, timeout=http_timeout) diff --git a/leap0/_sync/ssh.py b/leap0/_sync/ssh.py new file mode 100644 index 0000000..a430af4 --- /dev/null +++ b/leap0/_sync/ssh.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from typing import cast + +from ._transport import Transport +from .._utils.errors import intercept_errors +from ..models.sandbox import SandboxRef, sandbox_id_of +from ..models.ssh import SshAccess, SshValidation + + +class SshClient: + """Manage SSH access credentials for a sandbox. + + Each sandbox supports a single set of SSH credentials at a time. Creating + access when credentials already exist returns 409 Conflict. Use + :meth:`regenerate_access` to rotate credentials without revoking first. + + Example: + ```python + sandbox = client.sandboxes.create() + access = sandbox.ssh.create_access() + print(access.command) + ``` + + Attributes: + None. + """ + + def __init__(self, transport: Transport): + self._transport = transport + + @intercept_errors("Failed to create SSH access: ") + def create_access(self, sandbox: SandboxRef, http_timeout: float | None = None) -> SshAccess: + """Generate SSH credentials for a sandbox. + + Returns an access ID (used as the SSH username), a password, and the + full SSH command to connect. + + Args: + sandbox: Sandbox ID or object. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + SshAccess: Generated SSH credential bundle. + """ + data = self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/ssh/access", expected_status=201, timeout=http_timeout) + return SshAccess.from_dict(cast(dict, data)) + + @intercept_errors("Failed to delete SSH access: ") + def delete_access(self, sandbox: SandboxRef, http_timeout: float | None = None) -> None: + """Revoke SSH access for a sandbox. The credential is invalidated immediately. + + Args: + sandbox: Sandbox ID or object. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + self._transport.request("DELETE", f"/v1/sandbox/{sandbox_id_of(sandbox)}/ssh/access", expected_status=204, timeout=http_timeout) + + @intercept_errors("Failed to validate SSH access: ") + def validate_access(self, sandbox: SandboxRef, *, access_id: str, password: str, http_timeout: float | None = None) -> SshValidation: + """Check whether an SSH access credential is still valid and not expired. + + Args: + sandbox: Sandbox ID or object. + access_id: SSH access identifier. + password: SSH password. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + SshValidation: Validation result for the supplied credential pair. + """ + data = self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/ssh/validate", + json={"id": access_id, "password": password}, + timeout=http_timeout, + ) + return SshValidation.from_dict(cast(dict, data)) + + @intercept_errors("Failed to regenerate SSH access: ") + def regenerate_access(self, sandbox: SandboxRef, http_timeout: float | None = None) -> SshAccess: + """Invalidate the current credential and generate a new one. The expiry is also reset. + + Args: + sandbox: Sandbox ID or object. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + object: Result returned by this operation. + """ + data = self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/ssh/regen", timeout=http_timeout) + return SshAccess.from_dict(cast(dict, data)) diff --git a/leap0/_sync/templates.py b/leap0/_sync/templates.py new file mode 100644 index 0000000..658eb8b --- /dev/null +++ b/leap0/_sync/templates.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from typing import cast + +from ._transport import Transport +from .._utils.errors import intercept_errors +from ..models.template import ( + CreateTemplateParams, + RegistryCredentialsDict, + RenameTemplateParams, + Template, +) +from .._schemas.template import UploadTemplateResponseDict + + +class TemplatesClient: + """Create, rename, and delete sandbox templates. + + A template is a container image that has been converted into a sandbox + root filesystem. Sandboxes are always created from a template. + + Example: + ```python + template = client.templates.create( + name="my-template", + uri="docker.io/library/python:3.12", + ) + print(template.id) + ``` + + Attributes: + None. + """ + + 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. + + Args: + name: Template name. Must not start with ``system/`` or contain whitespace. + uri: Container image URI to pull and convert (e.g. ``docker.io/library/python:3.12``). + credentials: Optional registry credentials for private images. + Supports basic, AWS, GCP, and Azure authentication. + + Returns: + Template: Uploaded template metadata. + """ + payload = CreateTemplateParams(name=name, uri=uri, credentials=credentials).to_payload() + data = cast(UploadTemplateResponseDict, self._transport.request_json("POST", "/v1/template", json=payload, expected_status=201)) + return Template.from_dict(data) + + @intercept_errors("Failed to rename template: ") + def rename(self, template_id: str, *, name: str, http_timeout: float | None = None) -> None: + """Rename an existing template. + + Args: + template_id: ID of the template to rename. + name: New template name. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + payload = RenameTemplateParams(name=name).to_payload() + self._transport.request("PATCH", f"/v1/template/{template_id}", json=payload, expected_status=204, timeout=http_timeout) + + @intercept_errors("Failed to delete template: ") + def delete(self, template_id: str, http_timeout: float | None = None) -> None: + """Delete a template by ID. + + Args: + template_id: ID of the template to delete. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + """ + self._transport.request("DELETE", f"/v1/template/{template_id}", expected_status=204, timeout=http_timeout) diff --git a/leap0/_transport.py b/leap0/_transport.py deleted file mode 100644 index a8532c6..0000000 --- a/leap0/_transport.py +++ /dev/null @@ -1,191 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import httpx - -from .common.config import DEFAULT_CLIENT_TIMEOUT -from .common.errors import raise_api_error - - -class Transport: - def __init__( - self, - *, - api_key: str, - base_url: str, - timeout: float = DEFAULT_CLIENT_TIMEOUT, - auth_header: str = "authorization", - bearer: bool = True, - ): - self.api_key = api_key - self.base_url = base_url.rstrip("/") - self.timeout = timeout - self.auth_header = auth_header - self.bearer = bearer - self._client = httpx.Client(timeout=timeout) - - @property - def auth_value(self) -> str: - if self.bearer and not self.api_key.lower().startswith("bearer "): - return f"Bearer {self.api_key}" - return self.api_key - - def close(self) -> None: - self._client.close() - - def headers(self, extra: dict[str, str] | None = None) -> dict[str, str]: - headers = {self.auth_header: self.auth_value} - if extra: - headers.update(extra) - return headers - - def _expected(self, expected_status: int | tuple[int, ...]) -> tuple[int, ...]: - return (expected_status,) if isinstance(expected_status, int) else expected_status - - def _target_url(self, target: str) -> str: - if target.startswith("https://") or target.startswith("http://"): - return target - return f"{self.base_url}{target}" - - 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_api_error( - response.status_code, - f"Request failed: {method} {target}", - body=response.text, - headers=dict(response.headers), - ) - return response - - def _request( - self, - method: str, - target: str, - *, - params: dict[str, Any] | None = None, - json: dict[str, Any] | None = None, - content: Any = None, - files: Any = None, - headers: dict[str, str] | None = None, - expected_status: int | tuple[int, ...] = 200, - timeout: float | None = None, - ) -> httpx.Response: - actual_target = self._target_url(target) - response = self._client.request( - method, - actual_target, - params=params, - json=json, - content=content, - files=files, - headers=self.headers(headers), - timeout=timeout or self.timeout, - ) - return self._check_response(response, method, target, expected_status) - - def _stream( - self, - method: str, - target: str, - *, - json: dict[str, Any] | None = None, - timeout: float | None = None, - ) -> httpx.Response: - effective = timeout if timeout is not None else self.timeout - timeout_dict = {"connect": effective, "read": effective, "write": effective, "pool": effective} - request = self._client.build_request( - method, - self._target_url(target), - json=json, - headers=self.headers(), - extensions={"timeout": timeout_dict}, - ) - 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_api_error(response.status_code, f"Request failed: {method} {target}", body=body, headers=hdrs) - return response - - def request( - self, - method: str, - path: str, - *, - params: dict[str, Any] | None = None, - json: dict[str, Any] | None = None, - content: Any = None, - files: Any = None, - headers: dict[str, str] | None = None, - expected_status: int | tuple[int, ...] = 200, - timeout: float | None = None, - ) -> httpx.Response: - url = f"{self.base_url}{path}" - actual_headers = self.headers(headers) - response = self._client.request( - method, - url, - params=params, - json=json, - content=content, - files=files, - headers=actual_headers, - timeout=timeout or self.timeout, - ) - return self._check_response(response, method, path, expected_status) - - def request_json( - self, - method: str, - path: str, - *, - params: dict[str, Any] | None = None, - json: dict[str, Any] | None = None, - content: Any = None, - headers: dict[str, str] | None = None, - expected_status: int | tuple[int, ...] = 200, - timeout: float | None = None, - ) -> dict[str, Any]: - """Like request() but parses and returns the JSON body as a dict.""" - resp = self.request( - method, - path, - params=params, - json=json, - content=content, - headers=headers, - expected_status=expected_status, - timeout=timeout, - ) - return resp.json() - - def request_target( - self, - method: str, - target: str, - *, - params: dict[str, Any] | None = None, - json: dict[str, Any] | None = None, - expected_status: int | tuple[int, ...] = 200, - ) -> httpx.Response: - """Send a request to an absolute URL (e.g. sandbox-domain URLs).""" - return self._request(method, target, params=params, json=json, expected_status=expected_status) - - def request_target_json( - self, - method: str, - target: str, - *, - params: dict[str, Any] | None = None, - json: dict[str, Any] | None = None, - expected_status: int | tuple[int, ...] = 200, - ) -> dict[str, Any]: - """Send a request to an absolute URL and return parsed JSON.""" - resp = self._request(method, target, params=params, json=json, expected_status=expected_status) - return resp.json() - - def stream(self, method: str, target: str, *, json: dict[str, Any] | None = None, timeout: float | None = None) -> httpx.Response: - return self._stream(method, target, json=json, timeout=timeout) diff --git a/leap0/_utils/encoding.py b/leap0/_utils/encoding.py index 27e35f9..1b51c4b 100644 --- a/leap0/_utils/encoding.py +++ b/leap0/_utils/encoding.py @@ -4,16 +4,20 @@ def b64encode_bytes(value: bytes) -> str: + """Encode bytes as a base64 string.""" return base64.b64encode(value).decode("ascii") def b64encode_text(value: str, encoding: str = "utf-8") -> str: + """Encode text as base64 using UTF-8.""" return b64encode_bytes(value.encode(encoding)) def b64decode_text(value: str, encoding: str = "utf-8") -> str: + """Decode a base64 string into UTF-8 text.""" return base64.b64decode(value).decode(encoding) def b64decode_bytes(value: str) -> bytes: + """Decode a base64 string into raw bytes.""" return base64.b64decode(value) diff --git a/leap0/_utils/errors.py b/leap0/_utils/errors.py index 386130e..7e260a4 100644 --- a/leap0/_utils/errors.py +++ b/leap0/_utils/errors.py @@ -2,63 +2,143 @@ import functools import inspect -from typing import Any, Callable, Generator, Iterator, TypeVar +from typing import Any, AsyncGenerator, Callable, Generator, Iterator, NoReturn, TypeVar, cast -from ..common.errors import Leap0Error, Leap0TimeoutError +from ..models.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,) +_HTTPX_CLOSED_CLIENT_MESSAGES = ( + "client has been closed", + "cannot send a request, as the client has been closed", +) + + +def _prefixed_message(message: str, message_prefix: str) -> str: + if not message_prefix or message.startswith(message_prefix): + return message + return f"{message_prefix}{message}" + + +def _raise_processed(prefix: str, exc: Exception) -> NoReturn: + """Raise a fresh SDK exception with consistent normalization.""" + if isinstance(exc, Leap0Error): + raise exc.__class__( + _prefixed_message(exc.message, prefix), + status_code=exc.status_code, + headers=exc.headers, + body=exc.body, + ) from None + + 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(_prefixed_message(str(exc), prefix)) from None + if isinstance(exc, (_httpx.ConnectError, _httpx.NetworkError)): + raise Leap0Error(_prefixed_message(str(exc), prefix)) from None + + if isinstance(exc, RuntimeError): + lowered = str(exc).lower() + if any(message in lowered for message in _HTTPX_CLOSED_CLIENT_MESSAGES): + raise Leap0Error( + _prefixed_message( + ( + f"{exc}: Leap0 client is closed. " + "Create a new client or keep operations within the client's context manager." + ), + prefix, + ) + ) from None + + raise Leap0Error(_prefixed_message(str(exc), prefix)) from exc 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) + _raise_processed(message_prefix, exc) + + +async def _wrap_async_generator(gen: Any, message_prefix: str) -> AsyncGenerator[Any, Any]: + try: + async for item in gen: + yield item + except Exception as exc: + _raise_processed(message_prefix, exc) + + +def _get_timeout_context(args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[Any | None, float | None]: + http_timeout = kwargs.get("http_timeout") + if http_timeout is None or not args: + return None, None + transport = getattr(args[0], "_transport", None) + if transport is None or not hasattr(transport, "override_timeout"): + return None, None + return transport, cast(float, http_timeout) # 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*. + """Decorator that normalizes transport and runtime failures into SDK errors. - 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``. + The decorator turns low-level exceptions into fresh ``Leap0Error`` subclasses with a + clear method-specific prefix, while preserving HTTP metadata when it + already exists on a caught ``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: + if inspect.isasyncgenfunction(fn): + @functools.wraps(fn) + async def async_gen_wrapper(*args: Any, **kwargs: Any) -> Any: + try: + transport, http_timeout = _get_timeout_context(args, kwargs) + if transport is not None: + async with transport.override_timeout(http_timeout): + result = fn(*args, **kwargs) + else: + result = fn(*args, **kwargs) + except Exception as exc: + _raise_processed(message_prefix, cast(Exception, exc)) + else: + return _wrap_async_generator(result, message_prefix) + + return async_gen_wrapper # type: ignore[return-value] + + if inspect.iscoroutinefunction(fn): + @functools.wraps(fn) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + try: + transport, http_timeout = _get_timeout_context(args, kwargs) + if transport is not None: + async with transport.override_timeout(http_timeout): + return await fn(*args, **kwargs) + return await fn(*args, **kwargs) + except Exception as exc: + _raise_processed(message_prefix, cast(Exception, exc)) + + return async_wrapper # type: ignore[return-value] + @functools.wraps(fn) def wrapper(*args: Any, **kwargs: Any) -> Any: try: - result = fn(*args, **kwargs) - except Leap0Error as exc: - _handle_leap0_error(exc, message_prefix) - raise + transport, http_timeout = _get_timeout_context(args, kwargs) + if transport is not None: + with transport.override_timeout(http_timeout): + result = fn(*args, **kwargs) + else: + result = fn(*args, **kwargs) except Exception as exc: - _raise_wrapped(message_prefix, exc) + _raise_processed(message_prefix, cast(Exception, exc)) else: if isinstance(result, (Iterator, Generator)) or inspect.isgenerator(result): return _wrap_generator(result, message_prefix) @@ -67,19 +147,3 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: 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/otel.py b/leap0/_utils/otel.py new file mode 100644 index 0000000..ba7aaf7 --- /dev/null +++ b/leap0/_utils/otel.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import asyncio +import functools +import time +from typing import Any, Callable, TypeVar, cast + +from opentelemetry import metrics, trace +from opentelemetry.trace import Status, StatusCode + +F = TypeVar("F", bound=Callable[..., Any]) + +_tracer = None +_meter = None +_histograms: dict[str, Any] = {} + + +def get_tracer() -> Any: + """Return the SDK OpenTelemetry tracer singleton.""" + global _tracer + if _tracer is None: + _tracer = trace.get_tracer("leap0-sdk-python") + return _tracer + + +def get_meter() -> Any: + """Return the SDK OpenTelemetry meter singleton.""" + global _meter + if _meter is None: + _meter = metrics.get_meter("leap0-sdk-python") + return _meter + + +def _metric_name(name: str) -> str: + return name.replace(".", "_").lower() + + +def with_instrumentation(name: str) -> Callable[[F], F]: + """Instrument a function with OpenTelemetry spans and duration metrics.""" + def decorator(func: F) -> F: + if asyncio.iscoroutinefunction(func): + + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + tracer = get_tracer() + meter = get_meter() + histogram_name = _metric_name(name) + histogram = _histograms.get(histogram_name) + if histogram is None: + histogram = meter.create_histogram( + f"{histogram_name}_duration", + description=f"Duration of {name}", + unit="ms", + ) + _histograms[histogram_name] = histogram + + start = time.time() + with tracer.start_as_current_span(name) as span: + try: + result = await func(*args, **kwargs) + span.set_status(Status(StatusCode.OK)) + histogram.record((time.time() - start) * 1000, {"status": "success"}) + return result + except Exception as exc: + span.set_status(Status(StatusCode.ERROR, str(exc))) + span.record_exception(exc) + histogram.record((time.time() - start) * 1000, {"status": "error"}) + raise + + return cast(F, async_wrapper) + + @functools.wraps(func) + def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + tracer = get_tracer() + meter = get_meter() + histogram_name = _metric_name(name) + histogram = _histograms.get(histogram_name) + if histogram is None: + histogram = meter.create_histogram( + f"{histogram_name}_duration", + description=f"Duration of {name}", + unit="ms", + ) + _histograms[histogram_name] = histogram + + start = time.time() + with tracer.start_as_current_span(name) as span: + try: + result = func(*args, **kwargs) + span.set_status(Status(StatusCode.OK)) + histogram.record((time.time() - start) * 1000, {"status": "success"}) + return result + except Exception as exc: + span.set_status(Status(StatusCode.ERROR, str(exc))) + span.record_exception(exc) + histogram.record((time.time() - start) * 1000, {"status": "error"}) + raise + + return cast(F, sync_wrapper) + + return decorator diff --git a/leap0/_utils/stream.py b/leap0/_utils/stream.py index fec3d4c..5a8d841 100644 --- a/leap0/_utils/stream.py +++ b/leap0/_utils/stream.py @@ -1,10 +1,12 @@ from __future__ import annotations import json -from typing import Any, Iterable, Iterator +from collections.abc import AsyncIterable, Iterable +from typing import Any, AsyncIterator, Iterator def iter_ndjson(lines: Iterable[str]) -> Iterator[dict[str, Any]]: + """Yield JSON objects from newline-delimited JSON input.""" for line in lines: raw = line.strip() if raw: @@ -28,6 +30,7 @@ def _parse_sse_data(data: str) -> dict[str, Any] | str: def iter_sse_events(lines: Iterable[str]) -> Iterator[dict[str, Any] | str]: + """Yield parsed events from an SSE line iterator.""" buffer: list[str] = [] for line in lines: stripped = line.rstrip("\r\n") @@ -45,3 +48,24 @@ def iter_sse_events(lines: Iterable[str]) -> Iterator[dict[str, Any] | str]: data_lines = [_sse_data_value(item) for item in buffer if item.startswith("data:")] if data_lines: yield _parse_sse_data("\n".join(data_lines)) + + +async def aiter_sse_events(lines: AsyncIterable[str]) -> AsyncIterator[dict[str, Any] | str]: + """Yield parsed events from an asynchronous SSE line iterator.""" + buffer: list[str] = [] + async for line in lines: + stripped = line.rstrip("\r\n") + if stripped == "": + if buffer: + data_lines = [_sse_data_value(item) for item in buffer if item.startswith("data:")] + if data_lines: + yield _parse_sse_data("\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 _parse_sse_data("\n".join(data_lines)) diff --git a/leap0/_utils/url.py b/leap0/_utils/url.py index 47c7837..947dc9d 100644 --- a/leap0/_utils/url.py +++ b/leap0/_utils/url.py @@ -2,10 +2,12 @@ def ensure_leading_slash(value: str) -> str: + """Return a path string that always starts with a slash.""" return value if value.startswith("/") else f"/{value}" def sandbox_base_url(sandbox_id: str, sandbox_domain: str | None, *, port: int | None = None) -> str: + """Build the base HTTPS URL for a sandbox-scoped endpoint.""" 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 @@ -14,6 +16,7 @@ def sandbox_base_url(sandbox_id: str, sandbox_domain: str | None, *, port: int | def websocket_url_from_http(url: str) -> str: + """Convert an HTTP or HTTPS URL into WS or WSS.""" if url.startswith("https://"): return url.replace("https://", "wss://", 1) if url.startswith("http://"): diff --git a/leap0/client.py b/leap0/client.py deleted file mode 100644 index a6c1607..0000000 --- a/leap0/client.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import annotations - -import os -from types import TracebackType -from typing import Self - -from ._transport import Transport -from .code_interpreter import CodeInterpreterClient -from .desktop import DesktopClient -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 -from .lsp import LspClient -from .process import ProcessClient -from .pty import PtyClient -from .sandboxes import SandboxesClient -from .ssh import SshClient -from .snapshots import SnapshotsClient -from .templates import TemplatesClient - - -class Leap0Client: - """Top-level client for the Leap0 API. - - Provides access to all service clients (sandboxes, snapshots, templates, - filesystem, git, process, pty, lsp, ssh, code_interpreter, - desktop). Can be used as a context manager to ensure the underlying HTTP - connection is closed on exit. - - Args: - 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. 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. - """ - 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 - - def __init__( - self, - *, - api_key: str | None = None, - base_url: str | None = None, - sandbox_domain: str | None = None, - timeout: float = DEFAULT_CLIENT_TIMEOUT, - auth_header: str = "authorization", - bearer: bool = True, - ): - 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=resolved_base_url, - timeout=timeout, - auth_header=auth_header, - bearer=bearer, - ) - 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) - self.git = GitClient(self._transport) - self.process = ProcessClient(self._transport) - self.pty = PtyClient(self._transport) - self.lsp = LspClient(self._transport) - self.ssh = SshClient(self._transport) - self.code_interpreter = CodeInterpreterClient(self._transport, sandbox_domain=resolved_sandbox_domain) - self.desktop = DesktopClient(self._transport, sandbox_domain=resolved_sandbox_domain) - - def close(self) -> None: - self._transport.close() - - def __enter__(self) -> Self: - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: TracebackType | None, - ) -> None: - self.close() - - -def Leap0(config: Leap0Config) -> Leap0Client: - """Create a :class:`Leap0Client` from a :class:`Leap0Config`.""" - return Leap0Client( - api_key=config.api_key, - base_url=config.base_url, - sandbox_domain=config.sandbox_domain, - timeout=config.timeout, - auth_header=config.auth_header, - bearer=config.bearer, - ) diff --git a/leap0/code_interpreter.py b/leap0/code_interpreter.py deleted file mode 100644 index 7441f7b..0000000 --- a/leap0/code_interpreter.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator -from typing import Any, cast - -import httpx - -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.code_interpreter import ( - CodeContext, CodeContextDict, CodeExecutionResult, CodeExecutionResultDict, StreamEvent, StreamEventDict, -) -from .common.sandbox import SandboxRef, sandbox_id_of - - -class CodeInterpreterClient: - """Execute code inside a sandbox using a managed interpreter runtime. - - Supports Python and TypeScript/JavaScript. Each execution can be linked - to a persistent context to share state across multiple calls. - """ - - def __init__(self, transport: Transport, *, sandbox_domain: str | None = None): - self._transport = transport - self._sandbox_domain = sandbox_domain.strip("/") if sandbox_domain else None - - def _request( - self, - method: str, - sandbox: SandboxRef, - path: str, - *, - json: dict[str, Any] | None = None, - expected_status: int | tuple[int, ...] = 200, - ) -> httpx.Response: - return self._transport.request_target( - method, - f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", - json=json, - expected_status=expected_status, - ) - - def _request_json( - self, - method: str, - sandbox: SandboxRef, - path: str, - *, - json: dict[str, Any] | None = None, - expected_status: int | tuple[int, ...] = 200, - ) -> dict[str, Any]: - return self._transport.request_target_json( - method, - 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. - - Args: - sandbox: Sandbox ID or object. - language: Language runtime (e.g. ``"python"``, ``"typescript"``). - cwd: Working directory (default ``"/home/user"``). - """ - payload: dict[str, Any] = {"language": language} - if cwd is not None: - payload["cwd"] = 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") - # Server wraps response in {"items": [...]} - 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, - *, - code: str, - language: str = "python", - context_id: str | None = None, - env_vars: dict[str, str] | None = None, - timeout_ms: int | None = None, - ) -> CodeExecutionResult: - """Execute code and wait for the full result. - - Args: - sandbox: Sandbox ID or object. - code: Source code to execute. - language: Language runtime (default ``"python"``). - context_id: Link to an existing context to share state. - Auto-generated if omitted. - env_vars: Environment variables for the execution. - timeout_ms: Execution timeout in milliseconds (default 30000). - """ - payload: dict[str, Any] = {"code": code, "language": language} - if context_id is not None: - payload["context_id"] = context_id - if env_vars is not None: - payload["env_vars"] = env_vars - if timeout_ms is not None: - 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) - - @intercept_errors("Failed to execute code: ") - def execute_stream( - self, - sandbox: SandboxRef, - *, - code: str, - language: str = "python", - context_id: str | None = None, - timeout_ms: int | None = None, - ) -> Iterator[StreamEvent]: - """Execute code and stream output events via SSE. - - Yields :class:`StreamEvent` objects with type ``"stdout"``, - ``"stderr"``, ``"exit"``, or ``"error"``. - - Args: - sandbox: Sandbox ID or object. - code: Source code to execute. - language: Language runtime (default ``"python"``). - context_id: Link to an existing context to share state. - timeout_ms: Execution timeout in milliseconds (default 30000). - """ - payload: dict[str, Any] = {"code": code, "language": language} - if context_id is not None: - payload["context_id"] = context_id - if timeout_ms is not None: - payload["timeout_ms"] = timeout_ms - response = self._transport.stream( - "POST", - f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}/execute/async", - json=payload, - ) - try: - for event in iter_sse_events(response.iter_lines()): - yield StreamEvent.from_dict(cast(StreamEventDict, event)) - finally: - response.close() diff --git a/leap0/common/config.py b/leap0/common/config.py deleted file mode 100644 index 5df00ad..0000000 --- a/leap0/common/config.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -import math -import os -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 - - -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. - - Args: - 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. 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. 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. - """ - api_key: str | None = None - base_url: str | None = None - sandbox_domain: str | None = None - timeout: float = DEFAULT_CLIENT_TIMEOUT - auth_header: str = "authorization" - 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: - 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") - - 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/common/pty.py b/leap0/common/pty.py deleted file mode 100644 index 52c5988..0000000 --- a/leap0/common/pty.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import TypedDict - -from websockets.sync.client import ClientConnection - - -class PtySessionInfoDict(TypedDict, total=False): - 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", ""), - 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 - raise TypeError(f"Unexpected message type from websocket: {type(message).__name__}") - - def close(self) -> None: - self.websocket.close() diff --git a/leap0/common/snapshot.py b/leap0/common/snapshot.py deleted file mode 100644 index 96ae738..0000000 --- a/leap0/common/snapshot.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import TypedDict - -from .sandbox import NetworkPolicyDict, SandboxState, _parse_sandbox_state - - -class SnapshotCreateResponseDict(TypedDict, total=False): - snapshot_id: str - name: str - template_id: str - vcpu: int - memory_mib: int - disk_mib: int - state: SandboxState | str - 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 = SandboxState.STARTING - 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=_parse_sandbox_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/template.py b/leap0/common/template.py deleted file mode 100644 index a7bea1b..0000000 --- a/leap0/common/template.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum -from typing import TypedDict - -from typing_extensions import Literal, NotRequired, Required, TypeAlias - - -class RegistryCredentialType(str, Enum): - BASIC = "basic" - AWS = "aws" - GCP = "gcp" - AZURE = "azure" - - -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): - entrypoint: list[str] | None - cmd: list[str] | None - working_dir: str - user: str - env: dict[str, str] | 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] | None = None - cmd: list[str] | None = None - working_dir: str = "" - user: str = "" - env: dict[str, str] | None = None - - @classmethod - def from_dict(cls, data: ImageConfigDict) -> ImageConfig: - return cls( - entrypoint=data.get("entrypoint"), - cmd=data.get("cmd"), - 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: - 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=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=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 deleted file mode 100644 index b887983..0000000 --- a/leap0/desktop.py +++ /dev/null @@ -1,436 +0,0 @@ -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, 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, Leap0TimeoutError -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, - DesktopWindowsDict, -) -from .common.sandbox import SandboxRef, sandbox_id_of - - -class DesktopClient: - """Control a graphical Linux desktop inside a sandbox. - - 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): - self._transport = transport - self._sandbox_domain = sandbox_domain.strip("/") if sandbox_domain else None - - def _request( - self, - method: str, - sandbox: SandboxRef, - path: str, - *, - params: dict[str, Any] | None = None, - json: dict[str, Any] | None = None, - expected_status: int | tuple[int, ...] = 200, - ) -> httpx.Response: - return self._transport.request_target( - method, - f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", - params=params, - json=json, - expected_status=expected_status, - ) - - def _request_json( - self, - method: str, - sandbox: SandboxRef, - path: str, - *, - params: dict[str, Any] | None = None, - json: dict[str, Any] | None = None, - expected_status: int | tuple[int, ...] = 200, - ) -> dict[str, Any]: - return self._transport.request_target_json( - method, - f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}{path}", - params=params, - json=json, - expected_status=expected_status, - ) - - def desktop_url(self, sandbox: SandboxRef) -> str: - """Build the browser URL for the noVNC desktop viewer.""" - 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] - "POST", - sandbox, - "/api/display/screen", - json={"width": width, "height": height}, - ) - 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, - *, - image_format: str | None = None, - quality: int | None = None, - x: int | None = None, - y: int | None = None, - width: int | None = None, - height: int | None = None, - ) -> bytes: - """Take a screenshot and return the image as bytes. - - Args: - sandbox: Sandbox ID or object. - image_format: ``"png"``, ``"jpg"``, or ``"jpeg"`` (default ``"png"``). - quality: JPEG quality (1-100). - x: Left edge of capture region. - y: Top edge of capture region. - width: Region width in pixels. - height: Region height in pixels. - """ - params: dict[str, Any] = {} - 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 - 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, - *, - x: int, - y: int, - width: int, - height: int, - image_format: str | None = None, - quality: int | None = None, - ) -> bytes: - """Take a screenshot of a specific region and return the image as bytes.""" - payload: dict[str, Any] = {"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 - 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, - *, - x: int | None = None, - y: int | None = None, - button: int | None = None, - ) -> DesktopPointerPosition: - """Click the mouse. Clicks at the current position if coordinates are omitted. - - Args: - sandbox: Sandbox ID or object. - x: X coordinate. - y: Y coordinate. - button: 1=left, 2=middle, 3=right (default 1). - """ - payload: dict[str, Any] = {} - if x is not None: - payload["x"] = x - if y is not None: - payload["y"] = y - if button is not None: - payload["button"] = button - 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, - *, - from_x: int, - from_y: int, - to_x: int, - to_y: int, - button: int | None = None, - ) -> DesktopPointerPosition: - """Drag from one position to another.""" - payload: dict[str, Any] = { - "from_x": from_x, - "from_y": from_y, - "to_x": to_x, - "to_y": to_y, - } - if button is not None: - payload["button"] = button - 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. - - Args: - sandbox: Sandbox ID or object. - direction: ``"up"``, ``"down"``, ``"left"``, or ``"right"``. - amount: Number of scroll steps (1-100, default 1). - """ - payload: dict[str, Any] = {"direction": direction} - if amount is not None: - payload["amount"] = amount - 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] - 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, *, 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 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() - - 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. - """ - - def _is_transient_leap0(exc: BaseException) -> bool: - """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(_is_transient_leap0), - reraise=True, - ) - def _poll() -> None: - 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}" - ) from exc - except Exception as exc: - raise Leap0TimeoutError( - f"Desktop did not become ready within {timeout:.0f}s" - ) from exc diff --git a/leap0/filesystem.py b/leap0/filesystem.py deleted file mode 100644 index b837dd3..0000000 --- a/leap0/filesystem.py +++ /dev/null @@ -1,335 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from ._transport import Transport -from ._utils.errors import intercept_errors -from .common.filesystem import ( - EditFileResponseDict, - EditFileResult, - EditFilesResponseDict, - EditResult, - ExistsResponseDict, - FileEdit, - FileInfo, - FileInfoDict, - GlobResponseDict, - GrepResponseDict, - LsResponseDict, - LsResult, - SearchMatch, - TreeResponseDict, - TreeResult, -) -from .common.sandbox import SandboxRef, sandbox_id_of - - -class FilesystemClient: - """List, read, write, move, copy, delete, and search files inside a sandbox. - - File content is transferred as raw binary bytes. - """ - - 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. - - Args: - sandbox: Sandbox ID or object. - path: Directory path to list. - recursive: List recursively. - exclude: Glob patterns to exclude. - """ - payload: dict[str, Any] = {"path": path, "recursive": recursive} - if exclude is not None: - payload["exclude"] = 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. - - Args: - sandbox: Sandbox ID or object. - path: Directory path to create. - recursive: Create parents as needed. - permissions: Octal permission string (e.g. ``"755"``). - """ - payload: dict[str, Any] = {"path": path, "recursive": recursive} - if permissions is not None: - payload["permissions"] = permissions - self._transport.request( - "POST", - f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/mkdir", - json=payload, - 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} - if permissions is not None: - params["permissions"] = permissions - self._transport.request( - "POST", - f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/write-file", - params=params, - content=content, - headers={"Content-Type": "application/octet-stream"}, - 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. - - Args: - sandbox: Sandbox ID or object. - files: Mapping of file path to raw bytes content. - """ - self._transport.request( - "POST", - f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/write-files", - files=[(path, data) for path, data in files.items()], - 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, - *, - path: str, - offset: int | None = None, - limit: int | None = None, - head: int | None = None, - tail: int | None = None, - ) -> bytes: - """Read a single file and return its raw bytes. - - Args: - sandbox: Sandbox ID or object. - path: Path to the file. - offset: Byte offset to start from. - limit: Maximum bytes to read. - head: Return only the first N lines (mutually exclusive with *tail*). - tail: Return only the last N lines (mutually exclusive with *head*). - """ - payload: dict[str, Any] = {"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 - 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, - *, - path: str, - offset: int | None = None, - limit: int | None = None, - head: int | None = None, - tail: int | None = None, - encoding: str = "utf-8", - ) -> str: - """Read a single file and return its content decoded as a string.""" - return self.read_file_bytes( - sandbox, - path=path, - offset=offset, - limit=limit, - head=head, - 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( - "POST", - f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/delete", - json={"path": path, "recursive": recursive}, - expected_status=204, - ) - - @intercept_errors("Failed to set permissions: ") - def set_permissions( - self, - sandbox: SandboxRef, - *, - path: str, - mode: str | None = None, - owner: str | None = None, - group: str | None = None, - ) -> None: - """Set file mode and optionally change owner and group.""" - payload: dict[str, Any] = {"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 - 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} - if exclude is not None: - payload["exclude"] = exclude - 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. - - Args: - sandbox: Sandbox ID or object. - path: Base directory to search from. - pattern: Text pattern to search for. - include: File pattern filter (e.g. ``"*.py"``). - exclude: Glob patterns to exclude. - """ - payload: dict[str, Any] = {"path": path, "pattern": pattern} - if include is not None: - payload["include"] = include - if exclude is not None: - payload["exclude"] = exclude - 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. - - Returns the unified diff and total number of replacements made. - """ - data: EditFileResponseDict = self._transport.request_json( # type: ignore[assignment] - "POST", - f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/edit-file", - json={"path": path, "edits": [e.to_dict() for e in edits]}, - ) - 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] - "POST", - f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/edit-files", - json={"files": paths, "find": find, "replace": replace}, - ) - 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( - "POST", - f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/move", - json={"src_path": src_path, "dst_path": dst_path, "overwrite": overwrite}, - expected_status=204, - ) - - @intercept_errors("Failed to copy: ") - def copy( - self, - sandbox: SandboxRef, - *, - src_path: str, - dst_path: str, - recursive: bool = False, - overwrite: bool | None = None, - ) -> None: - """Copy a file or directory. Set *recursive* to copy directories with contents.""" - payload: dict[str, Any] = {"src_path": src_path, "dst_path": dst_path, "recursive": recursive} - if overwrite is not None: - 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. - - Args: - sandbox: Sandbox ID or object. - path: Root directory path. - max_depth: Maximum traversal depth. - exclude: Glob patterns to exclude. - """ - payload: dict[str, Any] = {"path": path} - if max_depth is not None: - payload["max_depth"] = max_depth - if exclude is not None: - payload["exclude"] = exclude - data: TreeResponseDict = self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/tree", json=payload) # type: ignore[assignment] - return TreeResult.from_dict(data) - - -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(): - body_preview = body[:200] if len(body) > 200 else body - raise ValueError( - f"Expected multipart response but got content_type={content_type!r} " - f"(body length={len(body)}, preview={body_preview!r})" - ) - 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 diff --git a/leap0/models/__init__.py b/leap0/models/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/leap0/models/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/leap0/common/code_interpreter.py b/leap0/models/code_interpreter.py similarity index 67% rename from leap0/common/code_interpreter.py rename to leap0/models/code_interpreter.py index 7af5305..716a1e5 100644 --- a/leap0/common/code_interpreter.py +++ b/leap0/models/code_interpreter.py @@ -5,11 +5,11 @@ import logging from dataclasses import dataclass, field from enum import Enum -from typing import Any, TypedDict +from typing import Any +from .._schemas.code_interpreter import CodeContextDict, CodeExecutionOutputDict, CodeExecutionResultDict, ExecutionErrorDict, ExecutionLogsDict, ListContextsResponseDict, StreamEventDict _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: @@ -20,72 +20,20 @@ def _decode_base64(data: str | None, label: str) -> bytes | None: _logger.debug("Failed to decode %s base64 data", label) return None - class CodeLanguage(str, Enum): + """Supported code interpreter languages.""" PYTHON = "python" TYPESCRIPT = "typescript" - class StreamEventType(str, Enum): + """Supported code interpreter stream event types.""" STDOUT = "stdout" STDERR = "stderr" EXIT = "exit" ERROR = "error" - -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 - - -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) -> CodeLanguage | str: if isinstance(value, int): value = _LANGUAGE_INT_TO_STR.get(value, str(value)) @@ -96,9 +44,24 @@ def _normalize_language(value: int | str | None) -> CodeLanguage | str: except ValueError: return str(value) - @dataclass(slots=True) class CodeExecutionOutput: + """Single output item produced by a code execution. + + Attributes: + is_primary: Whether this item is the primary result. + text: Plain-text output. + png: Base64-encoded PNG payload. + svg: SVG payload. + html: HTML payload. + markdown: Markdown payload. + json_data: Structured JSON payload. + jpeg: Base64-encoded JPEG payload. + pdf: Base64-encoded PDF payload. + latex: LaTeX payload. + javascript: JavaScript payload. + extra: Additional provider-specific data. + """ is_primary: bool = False text: str | None = None png: str | None = None @@ -114,6 +77,14 @@ class CodeExecutionOutput: @classmethod def from_dict(cls, data: CodeExecutionOutputDict) -> CodeExecutionOutput: + """Build an execution output object from a wire-format dictionary. + + Args: + data: Output payload returned by the API. + + Returns: + CodeExecutionOutput: Parsed execution output. + """ return cls( is_primary=bool(data.get("is_primary", False)), text=data.get("text"), @@ -131,48 +102,62 @@ def from_dict(cls, data: CodeExecutionOutputDict) -> CodeExecutionOutput: @property def is_main_result(self) -> bool: + """Return whether this output item is the primary result.""" return self.is_primary def png_bytes(self) -> bytes | None: + """Return the PNG payload as decoded bytes when present.""" return _decode_base64(self.png, "png") def jpeg_bytes(self) -> bytes | None: + """Return the JPEG payload as decoded bytes when present.""" return _decode_base64(self.jpeg, "jpeg") def pdf_bytes(self) -> bytes | None: + """Return the PDF payload as decoded bytes when present.""" return _decode_base64(self.pdf, "pdf") - @dataclass(slots=True) class CodeExecutionError: + """Structured code execution error details.""" name: str value: str traceback: str @classmethod def from_dict(cls, data: ExecutionErrorDict) -> CodeExecutionError: + """Build an instance from a wire-format dictionary.""" return cls( name=data.get("name", ""), value=data.get("value", ""), traceback=data.get("traceback", ""), ) - @dataclass(slots=True) class ExecutionLogs: + """Captured stdout and stderr logs from code execution.""" stdout: list[str] = field(default_factory=list) stderr: list[str] = field(default_factory=list) @classmethod def from_dict(cls, data: ExecutionLogsDict) -> ExecutionLogs: + """Build an instance from a wire-format dictionary.""" return cls( stdout=data.get("stdout") or [], stderr=data.get("stderr") or [], ) - @dataclass(slots=True) class CodeExecutionResult: + """Full result of a code execution request. + + Attributes: + items: Output items produced by the execution. + logs: Captured stdout and stderr logs. + error: Structured execution error, if any. + execution_count: Execution counter when provided by the runtime. + context_id: Context identifier associated with the execution. + """ items: list[CodeExecutionOutput] logs: ExecutionLogs error: CodeExecutionError | None @@ -181,6 +166,14 @@ class CodeExecutionResult: @classmethod def from_dict(cls, data: CodeExecutionResultDict) -> CodeExecutionResult: + """Build an execution result from a wire-format dictionary. + + Args: + data: Execution result payload returned by the API. + + Returns: + CodeExecutionResult: Parsed execution result. + """ error = data.get("error") logs_data = data.get("logs", {}) return cls( @@ -193,22 +186,28 @@ def from_dict(cls, data: CodeExecutionResultDict) -> CodeExecutionResult: @property def main_text(self) -> str | None: + """Return the primary text result when available. + + Returns: + str | None: Primary textual output, or ``None`` if no primary text result exists. + """ 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"} @dataclass(slots=True) class StreamEvent: + """Single streamed event from code execution.""" type: StreamEventType | str data: str = "" code: int | None = None @classmethod def from_dict(cls, data: StreamEventDict) -> StreamEvent: + """Build an instance from a wire-format dictionary.""" raw_type = data.get("type", "") if isinstance(raw_type, int): event_type = _STREAM_TYPE_INT_TO_STR.get(raw_type, str(raw_type)) @@ -224,15 +223,16 @@ def from_dict(cls, data: StreamEventDict) -> StreamEvent: code=data.get("code"), ) - @dataclass(slots=True) class CodeContext: + """Persistent code execution context.""" id: str language: CodeLanguage | str = "" cwd: str = "" @classmethod def from_dict(cls, data: CodeContextDict) -> CodeContext: + """Build an instance from a wire-format dictionary.""" return cls( id=data.get("id", ""), language=_normalize_language(data.get("language")), diff --git a/leap0/models/config.py b/leap0/models/config.py new file mode 100644 index 0000000..ac2f472 --- /dev/null +++ b/leap0/models/config.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import math +import os +from pydantic import BaseModel, ConfigDict, model_validator + +DEFAULT_BASE_URL = "https://api.leap0.dev" + +DEFAULT_SANDBOX_DOMAIN = "sandbox.leap0.dev" + +DEFAULT_TEMPLATE_NAME = "system/debian:bookworm" + +DEFAULT_CODE_INTERPRETER_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 + +def _resolve_env_str(value: str | None, env_var: str, default: str) -> str: + 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 + +class Leap0Config(BaseModel): + """Configuration for a Leap0 client.""" + + model_config = ConfigDict(extra="forbid") + + api_key: str | None = None + base_url: str | None = None + sandbox_domain: str | None = None + timeout: float = DEFAULT_CLIENT_TIMEOUT + auth_header: str = "authorization" + bearer: bool = True + otel_enabled: bool | None = None + + @model_validator(mode="after") + def _resolve_and_validate(self) -> Leap0Config: + try: + 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 timeout <= 0 or not math.isfinite(timeout): + raise ValueError(f"timeout must be a positive, finite number, got {self.timeout!r}") + + api_key = self.api_key + if api_key is None: + api_key = os.environ.get("LEAP0_API_KEY") + api_key = api_key.strip() if api_key else api_key + if not api_key: + raise ValueError("api_key is required or set LEAP0_API_KEY") + + auth_header = self.auth_header.strip() + if not auth_header: + raise ValueError("auth_header must be a non-empty string") + + self.timeout = timeout + self.api_key = api_key + self.auth_header = auth_header + if self.otel_enabled is None: + self.otel_enabled = os.environ.get("LEAP0_OTEL_ENABLED") == "true" or bool( + os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") + ) + 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, + ) + return self diff --git a/leap0/common/desktop.py b/leap0/models/desktop.py similarity index 78% rename from leap0/common/desktop.py rename to leap0/models/desktop.py index e2c6126..62b6981 100644 --- a/leap0/common/desktop.py +++ b/leap0/models/desktop.py @@ -1,8 +1,9 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Literal, TypedDict, cast +from typing import Any +from .._schemas.desktop import DesktopDisplayInfoDict, DesktopHealthDict, DesktopPointerPositionDict, DesktopProcessErrorsDict, DesktopProcessLogsDict, DesktopProcessRestartDict, DesktopProcessStatusDict, DesktopProcessStatusListDict, DesktopRecordingStatusDict, DesktopRecordingSummaryDict, DesktopWindowDict, DesktopWindowsDict def _safe_int(value: Any, default: int = 0) -> int: """Parse *value* as an integer, returning *default* on ``None`` or invalid input.""" @@ -13,109 +14,25 @@ def _safe_int(value: Any, default: int = 0) -> int: except (TypeError, ValueError): return default - -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: + """Desktop display geometry information.""" display: str = "" width: int = 0 height: int = 0 @classmethod def from_dict(cls, data: DesktopDisplayInfoDict) -> DesktopDisplayInfo: + """Build an instance from a wire-format dictionary.""" return cls( display=data.get("display", ""), width=_safe_int(data.get("width"), 0), height=_safe_int(data.get("height"), 0), ) - @dataclass(slots=True) class DesktopWindow: + """Single desktop window description.""" id: str = "" desktop: int = 0 pid: int = 0 @@ -130,6 +47,7 @@ class DesktopWindow: @classmethod def from_dict(cls, data: DesktopWindowDict) -> DesktopWindow: + """Build an instance from a wire-format dictionary.""" return cls( id=data.get("id", ""), desktop=_safe_int(data.get("desktop"), 0), @@ -144,19 +62,20 @@ def from_dict(cls, data: DesktopWindowDict) -> DesktopWindow: focused=bool(data.get("focused", False)), ) - @dataclass(slots=True) class DesktopPointerPosition: + """Mouse pointer position on the desktop.""" x: int = 0 y: int = 0 @classmethod def from_dict(cls, data: DesktopPointerPositionDict) -> DesktopPointerPosition: + """Build an instance from a wire-format dictionary.""" return cls(x=_safe_int(data.get("x"), 0), y=_safe_int(data.get("y"), 0)) - @dataclass(slots=True) class DesktopRecordingStatus: + """Desktop recording state and active recording metadata.""" id: str = "" active: bool = False started_at: str = "" @@ -169,6 +88,7 @@ class DesktopRecordingStatus: @classmethod def from_dict(cls, data: DesktopRecordingStatusDict) -> DesktopRecordingStatus: + """Build an instance from a wire-format dictionary.""" return cls( id=data.get("id", ""), active=bool(data.get("active", False)), @@ -181,9 +101,9 @@ def from_dict(cls, data: DesktopRecordingStatusDict) -> DesktopRecordingStatus: resolution=data.get("resolution", ""), ) - @dataclass(slots=True) class DesktopRecordingSummary: + """Summary metadata for a saved recording.""" id: str = "" file_name: str = "" download: str = "" @@ -194,6 +114,7 @@ class DesktopRecordingSummary: @classmethod def from_dict(cls, data: DesktopRecordingSummaryDict) -> DesktopRecordingSummary: + """Build an instance from a wire-format dictionary.""" return cls( id=data.get("id", ""), file_name=data.get("file_name", ""), @@ -204,18 +125,19 @@ def from_dict(cls, data: DesktopRecordingSummaryDict) -> DesktopRecordingSummary active=bool(data.get("active", False)), ) - @dataclass(slots=True) class DesktopHealth: + """Desktop service health information.""" ok: bool = False @classmethod def from_dict(cls, data: DesktopHealthDict) -> DesktopHealth: + """Build an instance from a wire-format dictionary.""" return cls(ok=bool(data.get("ok", False))) - @dataclass(slots=True) class DesktopProcessStatus: + """Status of one desktop-side process.""" name: str = "" running: bool = False pid: int = 0 @@ -224,6 +146,7 @@ class DesktopProcessStatus: @classmethod def from_dict(cls, data: DesktopProcessStatusDict) -> DesktopProcessStatus: + """Build an instance from a wire-format dictionary.""" return cls( name=data.get("name", ""), running=bool(data.get("running", False)), @@ -232,9 +155,9 @@ def from_dict(cls, data: DesktopProcessStatusDict) -> DesktopProcessStatus: stderr_log=data.get("stderr_log", ""), ) - @dataclass(slots=True) class DesktopProcessStatusList: + """Collection of desktop process statuses.""" status: str = "" items: list[DesktopProcessStatus] = field(default_factory=list) running: int = 0 @@ -242,6 +165,7 @@ class DesktopProcessStatusList: @classmethod def from_dict(cls, data: DesktopProcessStatusListDict) -> DesktopProcessStatusList: + """Build an instance from a wire-format dictionary.""" raw_items = data.get("items") if not isinstance(raw_items, (list, tuple)): raw_items = [] @@ -256,36 +180,39 @@ def from_dict(cls, data: DesktopProcessStatusListDict) -> DesktopProcessStatusLi total=_safe_int(data.get("total"), 0), ) - @dataclass(slots=True) class DesktopProcessRestart: + """Result of restarting a desktop-side process.""" message: str = "" status: DesktopProcessStatus | None = None @classmethod def from_dict(cls, data: DesktopProcessRestartDict) -> DesktopProcessRestart: + """Build an instance from a wire-format dictionary.""" status = data.get("status") return cls( message=data.get("message", ""), status=DesktopProcessStatus.from_dict(status) if isinstance(status, dict) else None, # type: ignore[arg-type] ) - @dataclass(slots=True) class DesktopProcessLogs: + """Recent logs for a desktop-side process.""" process: str = "" logs: str = "" @classmethod def from_dict(cls, data: DesktopProcessLogsDict) -> DesktopProcessLogs: + """Build an instance from a wire-format dictionary.""" return cls(process=data.get("process", ""), logs=data.get("logs", "")) - @dataclass(slots=True) class DesktopProcessErrors: + """Recent errors for a desktop-side process.""" process: str = "" errors: str = "" @classmethod def from_dict(cls, data: DesktopProcessErrorsDict) -> DesktopProcessErrors: + """Build an instance from a wire-format dictionary.""" return cls(process=data.get("process", ""), errors=data.get("errors", "")) diff --git a/leap0/common/errors.py b/leap0/models/errors.py similarity index 98% rename from leap0/common/errors.py rename to leap0/models/errors.py index acca4df..6b92577 100644 --- a/leap0/common/errors.py +++ b/leap0/models/errors.py @@ -4,7 +4,6 @@ from collections.abc import Mapping from typing import Any - class Leap0Error(Exception): """Base error for the Leap0 SDK. @@ -47,7 +46,6 @@ def __init__( detail = f"{detail}: {body}" super().__init__(detail) - class Leap0NotFoundError(Leap0Error): """The requested resource does not exist (HTTP 404). @@ -55,7 +53,6 @@ class Leap0NotFoundError(Leap0Error): SSH access, or other resource cannot be found. """ - class Leap0PermissionError(Leap0Error): """Permission denied for the requested operation (HTTP 403). @@ -63,7 +60,6 @@ class Leap0PermissionError(Leap0Error): file permissions or ownership. """ - class Leap0ConflictError(Leap0Error): """The operation conflicts with the current resource state (HTTP 409). @@ -71,7 +67,6 @@ class Leap0ConflictError(Leap0Error): directory), or when there are too many active sessions. """ - class Leap0RateLimitError(Leap0Error): """Rate limit exceeded (HTTP 429). @@ -79,19 +74,15 @@ class Leap0RateLimitError(Leap0Error): 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, @@ -99,7 +90,6 @@ class Leap0WebSocketError(Leap0Error): 429: Leap0RateLimitError, } - def raise_api_error( status_code: int, message: str, diff --git a/leap0/common/filesystem.py b/leap0/models/filesystem.py similarity index 54% rename from leap0/common/filesystem.py rename to leap0/models/filesystem.py index 38dd25d..9649912 100644 --- a/leap0/common/filesystem.py +++ b/leap0/models/filesystem.py @@ -1,71 +1,24 @@ 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] - +from .._schemas.filesystem import EditFileResponseDict, EditFilesResponseDict, EditResultDict, ExistsResponseDict, FileInfoDict, GlobResponseDict, GrepResponseDict, LsResponseDict, SearchMatchDict, TreeEntryDict, TreeResponseDict @dataclass(slots=True) class FileInfo: + """Filesystem metadata for a path inside a sandbox. + + Attributes: + name: Basename of the entry. + path: Full sandbox path. + is_dir: Whether the entry is a directory. + size: File size in bytes. + mode: POSIX mode string when available. + mtime: Last modification time as a unix timestamp. + owner: Entry owner. + group: Entry group. + is_symlink: Whether the entry is a symbolic link. + link_target: Symlink target when present. + """ name: str path: str is_dir: bool = False @@ -79,6 +32,14 @@ class FileInfo: @classmethod def from_dict(cls, data: FileInfoDict) -> FileInfo: + """Build a file info object from a wire-format dictionary. + + Args: + data: File metadata payload returned by the API. + + Returns: + FileInfo: Parsed file info object. + """ return cls( name=data.get("name", ""), path=data.get("path", ""), @@ -92,87 +53,126 @@ def from_dict(cls, data: FileInfoDict) -> FileInfo: link_target=data.get("link_target", ""), ) - @dataclass(slots=True) class LsResult: + """Directory listing result. + + Attributes: + items: Entries returned for the requested directory. + """ items: list[FileInfo] @classmethod def from_dict(cls, data: LsResponseDict) -> LsResult: - return cls(items=[FileInfo.from_dict(item) for item in data.get("items", [])]) + """Build a directory listing result from a wire-format dictionary. + + Args: + data: Directory listing payload returned by the API. + Returns: + LsResult: Parsed listing result. + """ + return cls(items=[FileInfo.from_dict(item) for item in data.get("items", [])]) @dataclass(slots=True) class FileEdit: + """Single find-and-replace edit specification. + + Attributes: + find: Text to search for. + replace: Replacement text. + """ find: str replace: str = "" def to_dict(self) -> dict[str, str]: - return {"find": self.find, "replace": self.replace} + """Convert this edit specification to an API payload. + Returns: + dict[str, str]: Serialized edit specification. + """ + return {"find": self.find, "replace": self.replace} @dataclass(slots=True) class EditFileResult: + """Result of editing a single file. + + Attributes: + diff: Unified diff describing the edit. + replacements: Number of replacements applied. + """ diff: str = "" replacements: int = 0 @classmethod def from_dict(cls, data: EditFileResponseDict) -> EditFileResult: + """Build a single-file edit result from a wire-format dictionary. + + Args: + data: Edit result payload returned by the API. + + Returns: + EditFileResult: Parsed edit result. + """ return cls( diff=data.get("diff", ""), replacements=int(data.get("replacements", 0)), ) - @dataclass(slots=True) class EditResult: + """Result of editing one file in a multi-file edit operation.""" file: str = "" success: bool = False error: str = "" @classmethod def from_dict(cls, data: EditResultDict) -> EditResult: + """Build an instance from a wire-format dictionary.""" return cls( file=data.get("file", ""), success=bool(data.get("success", False)), error=data.get("error", ""), ) - @dataclass(slots=True) class SearchMatch: + """Single filesystem search or grep match.""" path: str = "" line: int = 0 content: str = "" @classmethod def from_dict(cls, data: SearchMatchDict) -> SearchMatch: + """Build an instance from a wire-format dictionary.""" return cls( path=data.get("path", ""), line=int(data.get("line", 0)), content=data.get("content", ""), ) - @dataclass(slots=True) class TreeEntry: + """Single entry in a filesystem tree response.""" name: str type: str = "file" children: list[TreeEntry] = field(default_factory=list) @classmethod def from_dict(cls, data: TreeEntryDict) -> TreeEntry: + """Build an instance from a wire-format dictionary.""" 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: + """Recursive filesystem tree result.""" items: list[TreeEntry] @classmethod def from_dict(cls, data: TreeResponseDict) -> TreeResult: + """Build an instance from a wire-format dictionary.""" return cls(items=[TreeEntry.from_dict(item) for item in data.get("items", [])]) diff --git a/leap0/common/git.py b/leap0/models/git.py similarity index 70% rename from leap0/common/git.py rename to leap0/models/git.py index 9ffc9de..005e5c0 100644 --- a/leap0/common/git.py +++ b/leap0/models/git.py @@ -1,36 +1,28 @@ 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 - +from .._schemas.git import GitCommitResponseDict, GitResultDict @dataclass(slots=True) class GitResult: + """Git command result returned by the SDK.""" output: str exit_code: int @classmethod def from_dict(cls, data: GitResultDict) -> GitResult: + """Build an instance from a wire-format dictionary.""" return cls(output=data.get("output", ""), exit_code=int(data.get("exit_code", 0))) - @dataclass(slots=True) class GitCommitResult: + """Git commit result with commit metadata.""" sha: str | None result: GitResult | None @classmethod def from_dict(cls, data: GitCommitResponseDict) -> GitCommitResult: + """Build an instance from a wire-format dictionary.""" result_data = data.get("result") return cls( sha=data.get("sha"), diff --git a/leap0/common/lsp.py b/leap0/models/lsp.py similarity index 71% rename from leap0/common/lsp.py rename to leap0/models/lsp.py index 0cc67dc..2c079b2 100644 --- a/leap0/common/lsp.py +++ b/leap0/models/lsp.py @@ -1,52 +1,39 @@ from __future__ import annotations from dataclasses import dataclass -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 +from typing import Any +from .._schemas.lsp import LspJsonRpcErrorDict, LspJsonRpcResponseDict, LspSuccessResponseDict @dataclass(slots=True) class LspResponse: + """Basic response from LSP lifecycle operations.""" success: bool = False @classmethod def from_dict(cls, data: LspSuccessResponseDict) -> LspResponse: + """Build an instance from a wire-format dictionary.""" return cls(success=bool(data.get("success", False))) - @dataclass(slots=True) class LspJsonRpcError: + """JSON-RPC error returned by an LSP operation.""" code: int = 0 message: str = "" data: Any = None @classmethod def from_dict(cls, data: LspJsonRpcErrorDict) -> LspJsonRpcError: + """Build an instance from a wire-format dictionary.""" return cls( code=int(data.get("code", 0)), message=data.get("message", ""), data=data.get("data"), ) - @dataclass(slots=True) class LspJsonRpcResponse: + """JSON-RPC response returned by an LSP operation.""" jsonrpc: str = "" id: int | str | None = None result: Any = None @@ -54,6 +41,7 @@ class LspJsonRpcResponse: @classmethod def from_dict(cls, data: LspJsonRpcResponseDict) -> LspJsonRpcResponse: + """Build an instance from a wire-format dictionary.""" error = data.get("error") return cls( jsonrpc=data.get("jsonrpc", ""), diff --git a/leap0/common/process.py b/leap0/models/process.py similarity index 66% rename from leap0/common/process.py rename to leap0/models/process.py index 2728ef9..db5f950 100644 --- a/leap0/common/process.py +++ b/leap0/models/process.py @@ -1,19 +1,15 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TypedDict - - -class ProcessResultDict(TypedDict, total=False): - exit_code: int - result: str - +from .._schemas.process import ProcessResultDict @dataclass(slots=True) class ProcessResult: + """Result of a one-shot process execution.""" exit_code: int result: str @classmethod def from_dict(cls, data: ProcessResultDict) -> ProcessResult: + """Build an instance from a wire-format dictionary.""" return cls(exit_code=int(data.get("exit_code", 0)), result=data.get("result", "")) diff --git a/leap0/models/pty.py b/leap0/models/pty.py new file mode 100644 index 0000000..d3b1c81 --- /dev/null +++ b/leap0/models/pty.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pydantic import BaseModel, ConfigDict, model_validator +from websockets.sync.client import ClientConnection +from .._schemas.pty import PtyListResponseDict, PtySessionInfoDict + +class CreatePtySessionParams(BaseModel): + """Validated PTY session creation parameters.""" + model_config = ConfigDict(extra="forbid") + + session_id: str | None = None + cols: int | None = None + rows: int | None = None + cwd: str | None = None + envs: dict[str, str] | None = None + lazy_start: bool | None = None + + @model_validator(mode="after") + def _validate_values(self) -> CreatePtySessionParams: + if self.session_id is not None and not self.session_id.strip(): + raise ValueError("session_id must be a non-empty string when provided") + if self.cols is not None and self.cols < 1: + raise ValueError("cols must be at least 1 when provided") + if self.rows is not None and self.rows < 1: + raise ValueError("rows must be at least 1 when provided") + if self.cwd is not None and not self.cwd.strip(): + raise ValueError("cwd must be a non-empty string when provided") + if self.session_id is not None: + self.session_id = self.session_id.strip() + if self.cwd is not None: + self.cwd = self.cwd.strip() + return self + + def to_payload(self) -> dict[str, object]: + """Convert this object to an API request payload.""" + payload = self.model_dump(exclude_none=True) + session_id = payload.pop("session_id", None) + if session_id is not None: + payload["id"] = session_id + return payload + +@dataclass(slots=True) +class PtySession: + """PTY session metadata.""" + 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: + """Build an instance from a wire-format dictionary.""" + return cls( + id=data.get("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: + """Synchronous PTY websocket connection wrapper.""" + websocket: ClientConnection + + def send(self, data: str | bytes) -> None: + """Send data through the PTY websocket connection.""" + payload = data.encode() if isinstance(data, str) else data + self.websocket.send(payload) + + def recv(self) -> bytes: + """Receive data from the PTY websocket connection.""" + message = self.websocket.recv() + if isinstance(message, str): + return message.encode() + if isinstance(message, bytes): + return message + raise TypeError(f"Unexpected message type from websocket: {type(message).__name__}") + + def close(self) -> None: + """Close the client and release resources.""" + self.websocket.close() diff --git a/leap0/common/sandbox.py b/leap0/models/sandbox.py similarity index 53% rename from leap0/common/sandbox.py rename to leap0/models/sandbox.py index 74ebac9..80cc14e 100644 --- a/leap0/common/sandbox.py +++ b/leap0/models/sandbox.py @@ -2,12 +2,15 @@ from dataclasses import dataclass from enum import Enum -from typing import TypedDict - -from typing_extensions import NotRequired, Required +from typing import Protocol +from pydantic import BaseModel, ConfigDict, model_validator +from .._internal.types import JsonObject +from .config import DEFAULT_MEMORY_MIB, DEFAULT_TEMPLATE_NAME, DEFAULT_TIMEOUT_MIN, DEFAULT_VCPU +from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict, SandboxStatusResponseDict, TransformRuleDict class SandboxState(str, Enum): + """Lifecycle states for a sandbox.""" STARTING = "starting" SNAPSHOTTING = "snapshotting" RUNNING = "running" @@ -16,51 +19,53 @@ class SandboxState(str, Enum): DELETING = "deleting" DELETED = "deleted" - class NetworkPolicyMode(str, Enum): + """Available outbound network policy modes.""" ALLOW_ALL = "allow-all" DENY_ALL = "deny-all" CUSTOM = "custom" +class CreateSandboxParams(BaseModel): + """Validated sandbox creation parameters.""" + model_config = ConfigDict(extra="forbid") -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[NetworkPolicyMode | str] - 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 | str - 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 | str - auto_pause: bool - created_at: str + template_name: str = DEFAULT_TEMPLATE_NAME + vcpu: int = DEFAULT_VCPU + memory_mib: int = DEFAULT_MEMORY_MIB + timeout_min: int = DEFAULT_TIMEOUT_MIN + auto_pause: bool = False + telemetry: bool = False + env_vars: dict[str, str] | None = None + network_policy: NetworkPolicyDict | None = None + @model_validator(mode="after") + def _validate_values(self) -> CreateSandboxParams: + template_name = self.template_name.strip() + if not template_name: + raise ValueError("template_name must be a non-empty string") + if len(template_name) > 64: + raise ValueError("template_name must be at most 64 characters") + if not 1 <= self.vcpu <= 8: + raise ValueError("vcpu must be between 1 and 8") + if self.memory_mib < 512 or self.memory_mib > 8192 or self.memory_mib % 2 != 0: + raise ValueError("memory_mib must be an even number between 512 and 8192") + if not 1 <= self.timeout_min <= 480: + raise ValueError("timeout_min must be between 1 and 480") + self.template_name = template_name + return self + + def to_payload(self) -> JsonObject: + """Convert this object to an API request payload.""" + payload = self.model_dump(exclude_none=True) + payload["template_name"] = self.template_name.strip() + return payload + + +CreateSandboxParams.model_rebuild(_types_namespace={"NetworkPolicyMode": NetworkPolicyMode}) @dataclass(slots=True) class Sandbox: + """Sandbox model returned by sandbox creation APIs.""" id: str template_id: str = "" vcpu: int = 0 @@ -73,6 +78,7 @@ class Sandbox: @classmethod def from_dict(cls, data: SandboxCreateResponseDict) -> Sandbox: + """Build an instance from a wire-format dictionary.""" sandbox_id = data.get("id") 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}") @@ -89,9 +95,9 @@ def from_dict(cls, data: SandboxCreateResponseDict) -> Sandbox: network_policy=data.get("network_policy"), ) - @dataclass(slots=True) class SandboxStatus: + """Current status snapshot for a sandbox.""" id: str template_id: str vcpu: int @@ -103,6 +109,7 @@ class SandboxStatus: @classmethod def from_dict(cls, data: SandboxStatusResponseDict) -> SandboxStatus: + """Build an instance from a wire-format dictionary.""" 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}") @@ -118,9 +125,12 @@ def from_dict(cls, data: SandboxStatusResponseDict) -> SandboxStatus: created_at=data.get("created_at", ""), ) +class SandboxIdentifiable(Protocol): + """Protocol for objects exposing a sandbox ID.""" + id: str -SandboxRef = str | Sandbox | SandboxStatus +SandboxRef = str | SandboxIdentifiable def _parse_sandbox_state(value: SandboxState | str | None) -> SandboxState | str: if value is None: @@ -130,8 +140,8 @@ def _parse_sandbox_state(value: SandboxState | str | None) -> SandboxState | str except ValueError: return str(value) - def sandbox_id_of(value: SandboxRef) -> str: + """Return the sandbox ID for a sandbox reference.""" if isinstance(value, str): return value - return value.id + return str(value.id) diff --git a/leap0/models/snapshot.py b/leap0/models/snapshot.py new file mode 100644 index 0000000..fc9b995 --- /dev/null +++ b/leap0/models/snapshot.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from pydantic import BaseModel, ConfigDict, model_validator +from .sandbox import NetworkPolicyDict, NetworkPolicyMode, SandboxState, _parse_sandbox_state +from .._schemas.snapshot import SnapshotCreateResponseDict + +class CreateSnapshotParams(BaseModel): + """Validated snapshot creation parameters.""" + model_config = ConfigDict(extra="forbid") + + name: str | None = None + + @model_validator(mode="after") + def _validate_name(self) -> CreateSnapshotParams: + if self.name is not None: + name = self.name.strip() + if not name: + raise ValueError("name must be a non-empty string when provided") + if len(name) > 64: + raise ValueError("name must be at most 64 characters") + self.name = name + return self + + def to_payload(self) -> dict[str, str]: + """Convert this object to an API request payload.""" + if self.name is None: + return {} + return {"name": self.name} + +class ResumeSnapshotParams(BaseModel): + """Validated snapshot resume parameters.""" + model_config = ConfigDict(extra="forbid") + + snapshot_name: str + auto_pause: bool = False + timeout_min: int | None = None + network_policy: NetworkPolicyDict | None = None + + @model_validator(mode="after") + def _validate_values(self) -> ResumeSnapshotParams: + snapshot_name = self.snapshot_name.strip() + if not snapshot_name: + raise ValueError("snapshot_name must be a non-empty string") + if len(snapshot_name) > 64: + raise ValueError("snapshot_name must be at most 64 characters") + if self.timeout_min is not None and not 1 <= self.timeout_min <= 480: + raise ValueError("timeout_min must be between 1 and 480 when provided") + self.snapshot_name = snapshot_name + return self + + def to_payload(self) -> dict[str, object]: + """Convert this object to an API request payload.""" + payload = self.model_dump(exclude_none=True) + payload["snapshot_name"] = self.snapshot_name + return payload + + +CreateSnapshotParams.model_rebuild(_types_namespace={"NetworkPolicyMode": NetworkPolicyMode}) +ResumeSnapshotParams.model_rebuild(_types_namespace={"NetworkPolicyMode": NetworkPolicyMode}) + +@dataclass(slots=True) +class Snapshot: + """Snapshot metadata returned by the API.""" + snapshot_id: str + name: str + template_id: str = "" + vcpu: int = 0 + memory_mib: int = 0 + disk_mib: int = 0 + state: SandboxState | str = SandboxState.STARTING + network_policy: NetworkPolicyDict | None = None + created_at: str = "" + + @property + def id(self) -> str: + """Return the canonical identifier for this object.""" + return self.snapshot_id + + @classmethod + def from_dict(cls, data: SnapshotCreateResponseDict) -> Snapshot: + """Build an instance from a wire-format dictionary.""" + 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=_parse_sandbox_state(data.get("state")), + network_policy=data.get("network_policy"), + created_at=data.get("created_at", ""), + ) + +class SnapshotIdentifiable(Protocol): + """Protocol for objects exposing a snapshot ID.""" + snapshot_id: str + + +SnapshotRef = str | SnapshotIdentifiable + +def snapshot_id_of(value: SnapshotRef) -> str: + """Return the snapshot ID for a snapshot reference.""" + if isinstance(value, str): + return value + return value.snapshot_id diff --git a/leap0/common/ssh.py b/leap0/models/ssh.py similarity index 76% rename from leap0/common/ssh.py rename to leap0/models/ssh.py index 2aa829a..e006277 100644 --- a/leap0/common/ssh.py +++ b/leap0/models/ssh.py @@ -1,26 +1,11 @@ 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 - +from .._schemas.ssh import SshAccessValidationDict, SshCreateAccessDict @dataclass(slots=True) class SshAccess: + """SSH credential bundle for a sandbox.""" id: str password: str ssh_command: str @@ -31,6 +16,7 @@ class SshAccess: @classmethod def from_dict(cls, data: SshCreateAccessDict) -> SshAccess: + """Build an instance from a wire-format dictionary.""" return cls( id=data.get("id", ""), password=data.get("password", ""), @@ -41,14 +27,15 @@ def from_dict(cls, data: SshCreateAccessDict) -> SshAccess: updated_at=data.get("updated_at", ""), ) - @dataclass(slots=True) class SshValidation: + """Validation result for SSH credentials.""" valid: bool sandbox_id: str @classmethod def from_dict(cls, data: SshAccessValidationDict) -> SshValidation: + """Build an instance from a wire-format dictionary.""" return cls( valid=bool(data.get("valid", False)), sandbox_id=data.get("sandbox_id", ""), diff --git a/leap0/models/template.py b/leap0/models/template.py new file mode 100644 index 0000000..0f414ac --- /dev/null +++ b/leap0/models/template.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from pydantic import BaseModel, ConfigDict, model_validator +from .._schemas.template import AwsRegistryCredentialsDict, AzureRegistryCredentialsDict, BasicRegistryCredentialsDict, GcpRegistryCredentialsDict, ImageConfigDict, RegistryCredentialsDict, UploadTemplateResponseDict + +class RegistryCredentialType(str, Enum): + """Supported container registry credential types.""" + BASIC = "basic" + AWS = "aws" + GCP = "gcp" + AZURE = "azure" + +class CreateTemplateParams(BaseModel): + """Validated template creation parameters.""" + model_config = ConfigDict(extra="forbid") + + name: str + uri: str + credentials: RegistryCredentialsDict | None = None + + @model_validator(mode="after") + def _validate_values(self) -> CreateTemplateParams: + name = self.name.strip() + uri = self.uri.strip() + if not name: + raise ValueError("name must be a non-empty string") + if len(name) > 64: + raise ValueError("name must be at most 64 characters") + if name.startswith("system/"): + raise ValueError("name must not start with 'system/'") + if any(char.isspace() for char in name): + raise ValueError("name must not contain whitespace") + if not uri: + raise ValueError("uri must be a non-empty string") + if len(uri) > 500: + raise ValueError("uri must be at most 500 characters") + self.name = name + self.uri = uri + return self + + def to_payload(self) -> dict[str, object]: + """Convert this object to an API request payload.""" + return self.model_dump(exclude_none=True) + +class RenameTemplateParams(BaseModel): + """Validated template rename parameters.""" + model_config = ConfigDict(extra="forbid") + + name: str + + @model_validator(mode="after") + def _validate_name(self) -> RenameTemplateParams: + name = self.name.strip() + if not name: + raise ValueError("name must be a non-empty string") + if len(name) > 64: + raise ValueError("name must be at most 64 characters") + if name.startswith("system/"): + raise ValueError("name must not start with 'system/'") + if any(char.isspace() for char in name): + raise ValueError("name must not contain whitespace") + self.name = name + return self + + def to_payload(self) -> dict[str, str]: + """Convert this object to an API request payload.""" + return {"name": self.name} + + +CreateTemplateParams.model_rebuild(_types_namespace={"RegistryCredentialType": RegistryCredentialType}) + +@dataclass(slots=True) +class ImageConfig: + """Container image configuration metadata.""" + entrypoint: list[str] | None = None + cmd: list[str] | None = None + working_dir: str = "" + user: str = "" + env: dict[str, str] | None = None + + @classmethod + def from_dict(cls, data: ImageConfigDict) -> ImageConfig: + """Build an instance from a wire-format dictionary.""" + return cls( + entrypoint=data.get("entrypoint"), + cmd=data.get("cmd"), + working_dir=data.get("working_dir", ""), + user=data.get("user", ""), + env=data.get("env"), + ) + +@dataclass(slots=True) +class Template: + """Template metadata returned by the API.""" + 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: + """Build an instance from a wire-format dictionary.""" + 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=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=raw_system if isinstance(raw_system, bool) else False, + created_at=raw_created if isinstance(raw_created, str) else "", + ) diff --git a/leap0/process.py b/leap0/process.py deleted file mode 100644 index 45cac51..0000000 --- a/leap0/process.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from ._transport import Transport -from ._utils.errors import intercept_errors -from .common.process import ProcessResult, ProcessResultDict -from .common.sandbox import SandboxRef, sandbox_id_of - - -class ProcessClient: - """Execute one-shot shell commands inside a running sandbox.""" - - 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. - - The command runs inside ``/bin/sh -c``. - - Args: - sandbox: Sandbox ID or object. - command: Shell command to execute. - cwd: Working directory. - timeout: Timeout in seconds (default 30). - """ - payload: dict[str, Any] = {"command": command} - if cwd is not None: - payload["cwd"] = cwd - if timeout is not None: - payload["timeout"] = timeout - data: ProcessResultDict = self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/process/execute", json=payload) # type: ignore[assignment] - return ProcessResult.from_dict(data) diff --git a/leap0/pty.py b/leap0/pty.py deleted file mode 100644 index c34754a..0000000 --- a/leap0/pty.py +++ /dev/null @@ -1,100 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from websockets.sync.client import connect - -from ._transport import Transport -from ._utils.errors import intercept_errors -from ._utils.url import websocket_url_from_http -from .common.pty import PtyConnection, PtyListResponseDict, PtySession, PtySessionInfoDict -from .common.sandbox import SandboxRef, sandbox_id_of - - -class PtyClient: - """Create and manage interactive terminal sessions inside a sandbox. - - Connect via WebSocket for real-time bidirectional I/O, similar to SSH or - a browser-based terminal. - """ - - 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, - *, - session_id: str | None = None, - cols: int | None = None, - rows: int | None = None, - cwd: str | None = None, - envs: dict[str, str] | None = None, - lazy_start: bool | None = None, - ) -> PtySession: - """Create a terminal session with a shell process. - - Args: - sandbox: Sandbox ID or object. - session_id: Session ID. Auto-generated if omitted. - cols: Terminal columns. - rows: Terminal rows. - cwd: Starting directory. - envs: Environment variables. - lazy_start: Defer shell start until the first WebSocket connection. - """ - payload: dict[str, Any] = {} - if session_id is not None: - payload["id"] = session_id - if cols is not None: - payload["cols"] = cols - if rows is not None: - payload["rows"] = rows - if cwd is not None: - payload["cwd"] = cwd - if envs is not None: - payload["envs"] = envs - if lazy_start is not None: - payload["lazy_start"] = lazy_start - 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] - return PtySession.from_dict(data) - - 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. - - Returns a :class:`PtyConnection` with ``send``, ``recv``, and - ``close`` methods. All messages are binary frames containing raw - terminal bytes. - """ - url = self.websocket_url(sandbox, session_id) - websocket = connect(url, additional_headers={self._transport.auth_header: self._transport.auth_value}, **kwargs) - return PtyConnection(websocket=websocket) diff --git a/leap0/sandboxes.py b/leap0/sandboxes.py deleted file mode 100644 index 743a2f6..0000000 --- a/leap0/sandboxes.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import annotations - -import os -from typing import Any - -from ._transport import Transport -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 = ( - "OTEL_EXPORTER_OTLP_ENDPOINT", - "OTEL_EXPORTER_OTLP_HEADERS", -) - - -def _inject_otel_env(env_vars: dict[str, str] | None) -> dict[str, str] | None: - otel = {k: v for k in _OTEL_ENV_KEYS if (v := os.environ.get(k))} - if not otel and not env_vars: - return None - if not otel: - return env_vars - merged = dict(otel) - if env_vars: - merged.update(env_vars) - return merged - - -class SandboxesClient: - """Create, inspect, pause, and delete sandboxes.""" - - 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, - *, - template_name: str = DEFAULT_TEMPLATE_NAME, - vcpu: int = DEFAULT_VCPU, - memory_mib: int = DEFAULT_MEMORY_MIB, - timeout_min: int = DEFAULT_TIMEOUT_MIN, - auto_pause: bool = False, - telemetry: bool = False, - env_vars: dict[str, str] | None = None, - network_policy: NetworkPolicyDict | None = None, - ) -> Sandbox: - """Create a new sandbox from a template. - - Args: - template_name: Name of the template to use. - vcpu: Number of virtual CPUs (1 to 8). - memory_mib: Memory in MiB (512 to 8192, must be even). - timeout_min: Sandbox timeout in minutes (1 to 480, default 5). - auto_pause: Automatically pause the sandbox into a snapshot on timeout. - telemetry: Enable OpenTelemetry export. Reads ``OTEL_EXPORTER_OTLP_ENDPOINT`` - and ``OTEL_EXPORTER_OTLP_HEADERS`` from the local environment and - injects them into the sandbox. - env_vars: Environment variables to set inside the sandbox. - network_policy: Outbound network policy for the sandbox. - """ - merged_env = _inject_otel_env(env_vars) if telemetry else env_vars - - payload: dict[str, Any] = { - "template_name": template_name, - "vcpu": vcpu, - "memory_mib": memory_mib, - "timeout_min": timeout_min, - "auto_pause": auto_pause, - } - if merged_env is not None: - payload["env_vars"] = merged_env - if network_policy is not None: - payload["network_policy"] = network_policy - data: SandboxCreateResponseDict = self._transport.request_json( # type: ignore[assignment] - "POST", "/v1/sandbox", json=payload, expected_status=201, - ) - 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] - "POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pause", expected_status=201, - ) - 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] - "GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/", - ) - 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) - - def invoke_url(self, sandbox: SandboxRef, path: str = "/", *, port: int | None = None) -> str: - """Build an HTTPS URL that routes directly to the sandbox. - - Args: - sandbox: Sandbox ID or object. - path: Path to append after the host. - port: Optional port prefix for the sandbox subdomain. - """ - return f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain, port=port)}{ensure_leading_slash(path)}" - - def websocket_url(self, sandbox: SandboxRef, path: str = "/", *, port: int | None = None) -> str: - """Build a WSS URL that routes directly to the sandbox.""" - return websocket_url_from_http(self.invoke_url(sandbox, path, port=port)) diff --git a/leap0/snapshots.py b/leap0/snapshots.py deleted file mode 100644 index 81b2c9c..0000000 --- a/leap0/snapshots.py +++ /dev/null @@ -1,107 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from ._transport import Transport -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: - """Create, resume, and delete sandbox snapshots. - - A snapshot captures the full state of a running sandbox so it can be - restored later. - """ - - def __init__(self, transport: Transport): - self._transport = transport - - @intercept_errors("Failed to create snapshot: ") - def create( - self, - sandbox: SandboxRef, - *, - name: str | None = None, - ) -> Snapshot: - """Create a snapshot of a running sandbox without stopping it. - - Args: - sandbox: Sandbox ID or object to snapshot. - name: Optional snapshot name. Auto-generated if omitted. - """ - payload: dict[str, Any] = {} - if name is not None: - payload["name"] = name - data: SnapshotCreateResponseDict = self._transport.request_json( # type: ignore[assignment] - "POST", - f"/v1/sandbox/{sandbox_id_of(sandbox)}/snapshot/create", - json=payload, - expected_status=201, - ) - return Snapshot.from_dict(data) - - @intercept_errors("Failed to pause sandbox: ") - def pause( - self, - sandbox: SandboxRef, - *, - name: str | None = None, - ) -> Snapshot: - """Pause a running sandbox and create a snapshot in one step. - - The sandbox is stopped after the snapshot is taken. - - Args: - sandbox: Sandbox ID or object to pause. - name: Optional snapshot name. Auto-generated if omitted. - """ - payload: dict[str, Any] = {} - if name is not None: - payload["name"] = name - data: SnapshotCreateResponseDict = self._transport.request_json( # type: ignore[assignment] - "POST", - f"/v1/sandbox/{sandbox_id_of(sandbox)}/snapshot/pause", - json=payload, - expected_status=201, - ) - return Snapshot.from_dict(data) - - @intercept_errors("Failed to resume snapshot: ") - def resume( - self, - *, - snapshot_name: str, - auto_pause: bool = False, - timeout_min: int | None = None, - network_policy: NetworkPolicyDict | None = None, - ) -> Sandbox: - """Restore a sandbox from a snapshot. - - Args: - snapshot_name: Name of the snapshot to restore. - auto_pause: Automatically pause the restored sandbox on timeout. - timeout_min: Sandbox timeout in minutes. - network_policy: Override the network policy from the snapshot. - """ - payload: dict[str, Any] = { - "snapshot_name": snapshot_name, - "auto_pause": auto_pause, - } - if timeout_min is not None: - payload["timeout_min"] = timeout_min - if network_policy is not None: - payload["network_policy"] = network_policy - data: SandboxCreateResponseDict = self._transport.request_json( # type: ignore[assignment] - "POST", - "/v1/snapshot/resume", - json=payload, - expected_status=201, - ) - 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 deleted file mode 100644 index f7ccecc..0000000 --- a/leap0/ssh.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -from ._transport import Transport -from ._utils.errors import intercept_errors -from .common.sandbox import SandboxRef, sandbox_id_of -from .common.ssh import SshAccess, SshValidation - - -class SshClient: - """Manage SSH access credentials for a sandbox. - - Each sandbox supports a single set of SSH credentials at a time. Creating - access when credentials already exist returns 409 Conflict. Use - :meth:`regenerate_access` to rotate credentials without revoking first. - """ - - 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. - - Returns an access ID (used as the SSH username), a password, and the - full SSH command to connect. - """ - 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( - "POST", - f"/v1/sandbox/{sandbox_id_of(sandbox)}/ssh/validate", - json={"id": access_id, "password": password}, - ) - 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") - return SshAccess.from_dict(data) # type: ignore[arg-type] diff --git a/leap0/templates.py b/leap0/templates.py deleted file mode 100644 index 8f92081..0000000 --- a/leap0/templates.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from ._transport import Transport -from ._utils.errors import intercept_errors -from .common.template import RegistryCredentialsDict, Template, UploadTemplateResponseDict - - -class TemplatesClient: - """Create, rename, and delete sandbox templates. - - A template is a container image that has been converted into a sandbox - root filesystem. Sandboxes are always created from a template. - """ - - 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. - - Args: - name: Template name. Must not start with ``system/`` or contain whitespace. - uri: Container image URI to pull and convert (e.g. ``docker.io/library/python:3.12``). - credentials: Optional registry credentials for private images. - Supports basic, AWS, GCP, and Azure authentication. - """ - payload: dict[str, Any] = {"name": name, "uri": uri} - if credentials is not None: - payload["credentials"] = credentials - data: UploadTemplateResponseDict = self._transport.request_json("POST", "/v1/template", json=payload, expected_status=201) # type: ignore[assignment] - return Template.from_dict(data) - - @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. - """ - 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/pyproject.toml b/pyproject.toml index 94701fd..90d4ad2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,10 @@ authors = [ ] dependencies = [ "httpx>=0.27,<1", + "opentelemetry-api>=1.27,<2", + "opentelemetry-exporter-otlp-proto-http>=1.27,<2", + "opentelemetry-sdk>=1.27,<2", + "pydantic>=2.7,<3", "tenacity>=8,<10", "websockets>=12,<16", "typing-extensions>=4.0", diff --git a/tests/_async/__init__.py b/tests/_async/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/tests/_async/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/_async/test_client.py b/tests/_async/test_client.py new file mode 100644 index 0000000..dfaf106 --- /dev/null +++ b/tests/_async/test_client.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import asyncio + +from leap0._async.client import AsyncLeap0Client +from leap0._async.sandbox import AsyncSandbox +from leap0.models.sandbox import Sandbox + + +class _FakeAsyncTransport: + def __init__(self, **_kwargs): + self.closed = False + + async def close(self): + self.closed = True + + +def test_async_client_native_transport(monkeypatch): + import leap0._async.client as async_client_module + + monkeypatch.setattr(async_client_module, "AsyncTransport", _FakeAsyncTransport) + + async def run() -> None: + client = AsyncLeap0Client(api_key="test") + + async def fake_get(_sandbox_id: str) -> AsyncSandbox: + return AsyncSandbox(client, Sandbox(id="sbx-1", state="running")) + + client.sandboxes.get = fake_get # type: ignore[method-assign] + + sandbox = await client.get_sandbox("sbx-1") + assert isinstance(sandbox, AsyncSandbox) + + await client.close() + assert client._transport.closed is True + + asyncio.run(run()) diff --git a/tests/_async/test_filesystem.py b/tests/_async/test_filesystem.py new file mode 100644 index 0000000..9dda168 --- /dev/null +++ b/tests/_async/test_filesystem.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock + +from leap0._async.filesystem import AsyncFilesystemClient +from leap0.models.filesystem import FileEdit + + +class TestAsyncFilesystemClient: + def test_ls(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = {"items": []} + await AsyncFilesystemClient(async_mock_transport).ls("sbx-1", path="/workspace") + assert "/filesystem/ls" in async_mock_transport.request_json.call_args[0][1] + + asyncio.run(run()) + + def test_mkdir(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request.return_value = MagicMock(status_code=204) + await AsyncFilesystemClient(async_mock_transport).mkdir("sbx-1", path="/workspace/src", recursive=True) + assert async_mock_transport.request.call_args[1]["json"]["recursive"] is True + + asyncio.run(run()) + + def test_edit_file(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = {"diff": "...", "replacements": 1} + await AsyncFilesystemClient(async_mock_transport).edit_file("sbx-1", path="/a.py", edits=[FileEdit(find="old", replace="new")]) + assert async_mock_transport.request_json.call_args[1]["json"]["edits"] == [{"find": "old", "replace": "new"}] + + asyncio.run(run()) diff --git a/tests/_async/test_git.py b/tests/_async/test_git.py new file mode 100644 index 0000000..b750cfa --- /dev/null +++ b/tests/_async/test_git.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import asyncio + +from leap0._async.git import AsyncGitClient + + +class TestAsyncGitClient: + def test_clone(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = {"output": "cloned", "exit_code": 0} + await AsyncGitClient(async_mock_transport).clone("sbx-1", url="https://github.com/test/repo.git", path="/workspace/repo") + args, kwargs = async_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" + + asyncio.run(run()) + + def test_status(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = {"output": "", "exit_code": 0} + await AsyncGitClient(async_mock_transport).status("sbx-1", path="/workspace/repo") + assert "/git/status" in async_mock_transport.request_json.call_args[0][1] + + asyncio.run(run()) diff --git a/tests/_async/test_process.py b/tests/_async/test_process.py new file mode 100644 index 0000000..c2d66a7 --- /dev/null +++ b/tests/_async/test_process.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import asyncio + +from leap0._async.process import AsyncProcessClient + + +class TestAsyncProcessClient: + def test_execute(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = {"exit_code": 0, "result": "hello"} + result = await AsyncProcessClient(async_mock_transport).execute("sbx-1", command="echo hello") + assert result.exit_code == 0 + assert result.result == "hello" + assert async_mock_transport.request_json.call_args[0][:2] == ("POST", "/v1/sandbox/sbx-1/process/execute") + + asyncio.run(run()) diff --git a/tests/_async/test_sandboxes.py b/tests/_async/test_sandboxes.py new file mode 100644 index 0000000..831babe --- /dev/null +++ b/tests/_async/test_sandboxes.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +from leap0._async.sandbox import AsyncSandbox, AsyncSandboxesClient +from leap0.models.sandbox import Sandbox + + +class TestAsyncSandboxesClient: + def test_create(self, async_mock_transport): + async def run() -> None: + async_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 = await AsyncSandboxesClient(async_mock_transport, sandbox_domain="s.dev").create(template_name="my-tpl", vcpu=2, memory_mib=2048) + args, kwargs = async_mock_transport.request_json.call_args + assert args == ("POST", "/v1/sandbox") + assert kwargs["json"]["template_name"] == "my-tpl" + assert result.id == "sbx-1" + + asyncio.run(run()) + + def test_factory_returns_async_sandbox(self, async_mock_transport): + async def run() -> None: + fake_client = SimpleNamespace( + filesystem=SimpleNamespace(), git=SimpleNamespace(), process=SimpleNamespace(), pty=SimpleNamespace(), + lsp=SimpleNamespace(), ssh=SimpleNamespace(), code_interpreter=SimpleNamespace(), desktop=SimpleNamespace(), + ) + client = AsyncSandboxesClient(async_mock_transport, sandbox_domain="s.dev", sandbox_factory=lambda data: AsyncSandbox(fake_client, data)) + async_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 = await client.create(template_name="my-tpl") + assert isinstance(result, AsyncSandbox) + + asyncio.run(run()) diff --git a/tests/_async/test_snapshots.py b/tests/_async/test_snapshots.py new file mode 100644 index 0000000..098c3fd --- /dev/null +++ b/tests/_async/test_snapshots.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock + +from leap0._async.snapshots import AsyncSnapshotsClient +from leap0.models.snapshot import Snapshot + + +class TestAsyncSnapshotsClient: + def test_create(self, async_mock_transport): + async def run() -> None: + async_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": "", + } + await AsyncSnapshotsClient(async_mock_transport).create("sbx-1", name="my-snap") + args, kwargs = async_mock_transport.request_json.call_args + assert args[1] == "/v1/sandbox/sbx-1/snapshot/create" + assert kwargs["json"]["name"] == "my-snap" + + asyncio.run(run()) + + def test_delete_accepts_object(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request.return_value = MagicMock(status_code=204) + await AsyncSnapshotsClient(async_mock_transport).delete(Snapshot(snapshot_id="snap-obj", name="n")) + assert "snap-obj" in async_mock_transport.request.call_args[0][1] + + asyncio.run(run()) diff --git a/tests/_async/test_ssh.py b/tests/_async/test_ssh.py new file mode 100644 index 0000000..53db3f7 --- /dev/null +++ b/tests/_async/test_ssh.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock + +from leap0._async.ssh import AsyncSshClient + + +class TestAsyncSshClient: + def test_create_access(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = { + "id": "ssh-1", "password": "pw", "ssh_command": "ssh u@h", "sandbox_id": "sbx-1", + } + result = await AsyncSshClient(async_mock_transport).create_access("sbx-1") + assert result.id == "ssh-1" + + asyncio.run(run()) + + def test_delete_access(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request.return_value = MagicMock(status_code=204) + await AsyncSshClient(async_mock_transport).delete_access("sbx-1") + assert async_mock_transport.request.call_args[0][:2] == ("DELETE", "/v1/sandbox/sbx-1/ssh/access") + + asyncio.run(run()) diff --git a/tests/_async/test_templates.py b/tests/_async/test_templates.py new file mode 100644 index 0000000..1fb3b16 --- /dev/null +++ b/tests/_async/test_templates.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock + +from leap0._async.templates import AsyncTemplatesClient + + +class TestAsyncTemplatesClient: + def test_create(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = { + "id": "tpl-1", "name": "my-tpl", "digest": "sha256:abc", + "image_config": None, "is_system": False, "created_at": "", + } + result = await AsyncTemplatesClient(async_mock_transport).create(name="my-tpl", uri="docker.io/library/python:3.12") + args, _kwargs = async_mock_transport.request_json.call_args + assert args[1] == "/v1/template" + assert result.name == "my-tpl" + + asyncio.run(run()) + + def test_delete(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request.return_value = MagicMock(status_code=204) + await AsyncTemplatesClient(async_mock_transport).delete("tpl-1") + assert async_mock_transport.request.call_args[1]["expected_status"] == 204 + + asyncio.run(run()) diff --git a/tests/_async/test_transport.py b/tests/_async/test_transport.py new file mode 100644 index 0000000..ba58818 --- /dev/null +++ b/tests/_async/test_transport.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import httpx + +from leap0._async._transport import AsyncTransport + + +def test_async_transport_headers() -> None: + transport = AsyncTransport(api_key="test-key", base_url="https://api.example.com") + headers = transport.headers() + assert headers["authorization"] == "Bearer test-key" + assert headers["Leap0-Source"] == "sdk-python-async" + assert headers["Leap0-SDK-Version"] + assert headers["User-Agent"].startswith("leap0-python-async/") + asyncio.run(transport.close()) + + +def test_async_transport_request_json(async_mock_transport) -> None: + async def run() -> None: + transport = AsyncTransport(api_key="test-key", base_url="https://api.example.com") + response = MagicMock(spec=httpx.Response) + response.status_code = 200 + response.json.return_value = {"ok": True} + transport._client.request = AsyncMock(return_value=response) # type: ignore[method-assign] + data = await transport.request_json("GET", "/v1/test") + assert data == {"ok": True} + await transport.close() + + asyncio.run(run()) diff --git a/tests/_sync/__init__.py b/tests/_sync/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/tests/_sync/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/_sync/test_client_config.py b/tests/_sync/test_client_config.py new file mode 100644 index 0000000..e892201 --- /dev/null +++ b/tests/_sync/test_client_config.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from leap0.models.config import Leap0Config + + +def test_otel_enabled_defaults_from_standard_otel_env(monkeypatch): + monkeypatch.setenv("LEAP0_API_KEY", "test-key") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") + + config = Leap0Config() + + assert config.otel_enabled is True + + +def test_otel_enabled_can_be_disabled_explicitly(monkeypatch): + monkeypatch.setenv("LEAP0_API_KEY", "test-key") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") + + config = Leap0Config(otel_enabled=False) + + assert config.otel_enabled is False diff --git a/tests/_sync/test_filesystem.py b/tests/_sync/test_filesystem.py new file mode 100644 index 0000000..d6bb879 --- /dev/null +++ b/tests/_sync/test_filesystem.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from leap0._sync.filesystem import FilesystemClient, _parse_multipart_response +from leap0.models.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): + 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"}] + + def test_write_file(self, mock_transport): + mock_transport.request.return_value = MagicMock(status_code=204) + FilesystemClient(mock_transport).write_file("sbx-1", path="/workspace/hello.txt", content="Hello") + assert mock_transport.request.call_args[1]["content"] == b"Hello" + + def test_write_bytes(self, mock_transport): + mock_transport.request.return_value = MagicMock(status_code=204) + FilesystemClient(mock_transport).write_bytes("sbx-1", path="/workspace/hello.bin", content=b"Hello") + assert mock_transport.request.call_args[1]["headers"]["Content-Type"] == "application/octet-stream" + + def test_read_file(self, mock_transport): + mock_transport.request.return_value = MagicMock(content=b"Hello") + assert FilesystemClient(mock_transport).read_file("sbx-1", path="/workspace/hello.txt") == "Hello" + + def test_read_bytes(self, mock_transport): + mock_transport.request.return_value = MagicMock(content=b"Hello") + assert FilesystemClient(mock_transport).read_bytes("sbx-1", path="/workspace/hello.bin") == b"Hello" + + def test_write_files(self, mock_transport): + mock_transport.request.return_value = MagicMock(status_code=204) + FilesystemClient(mock_transport).write_files("sbx-1", files={"/workspace/hello.txt": "Hello"}) + assert mock_transport.request.call_args[1]["files"] == [("/workspace/hello.txt", b"Hello")] + + def test_read_files(self, mock_transport): + 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() + mock_transport.request.return_value = MagicMock( + content=body, + headers={"content-type": f"multipart/form-data; boundary={boundary}"}, + ) + assert FilesystemClient(mock_transport).read_files("sbx-1", paths=["/a.txt"]) == {"/a.txt": "content a"} + + +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/_sync/test_git.py similarity index 95% rename from tests/test_git.py rename to tests/_sync/test_git.py index 6333758..eec1e0b 100644 --- a/tests/test_git.py +++ b/tests/_sync/test_git.py @@ -1,6 +1,6 @@ from __future__ import annotations -from leap0.git import GitClient +from leap0._sync.git import GitClient class TestGitClient: diff --git a/tests/test_process.py b/tests/_sync/test_process.py similarity index 91% rename from tests/test_process.py rename to tests/_sync/test_process.py index 5e410fc..3e8d26b 100644 --- a/tests/test_process.py +++ b/tests/_sync/test_process.py @@ -1,6 +1,6 @@ from __future__ import annotations -from leap0.process import ProcessClient +from leap0._sync.process import ProcessClient class TestProcessClient: diff --git a/tests/_sync/test_pty.py b/tests/_sync/test_pty.py new file mode 100644 index 0000000..23f9339 --- /dev/null +++ b/tests/_sync/test_pty.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import pytest + +from leap0.models.pty import CreatePtySessionParams +from leap0._sync.pty import PtyClient + + +class TestPtyClient: + def test_create_builds_payload(self, mock_transport): + mock_transport.request_json.return_value = {"id": "pty-1", "cols": 120, "rows": 30} + + PtyClient(mock_transport).create("sbx-1", session_id=" sess ", cols=120, rows=30, cwd=" /workspace ") + + args, kwargs = mock_transport.request_json.call_args + assert args == ("POST", "/v1/sandbox/sbx-1/pty") + assert kwargs["json"]["id"] == "sess" + assert kwargs["json"]["cwd"] == "/workspace" + + +class TestCreatePtySessionParams: + def test_invalid_cols(self): + with pytest.raises(ValueError, match="cols"): + CreatePtySessionParams(cols=0) diff --git a/tests/_sync/test_sandboxes.py b/tests/_sync/test_sandboxes.py new file mode 100644 index 0000000..988804c --- /dev/null +++ b/tests/_sync/test_sandboxes.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from unittest.mock import MagicMock +from types import SimpleNamespace + +import pytest + +from leap0.models.errors import Leap0Error +from leap0._sync.sandbox import Sandbox as RichSandbox, SandboxesClient +from leap0.models.sandbox import CreateSandboxParams, 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" + + def test_factory_returns_rich_sandbox(self, mock_transport): + fake_client = SimpleNamespace( + filesystem=MagicMock(), + git=MagicMock(), + process=MagicMock(), + pty=MagicMock(), + lsp=MagicMock(), + ssh=MagicMock(), + code_interpreter=MagicMock(), + desktop=MagicMock(), + ) + client = SandboxesClient( + mock_transport, + sandbox_domain="s.dev", + sandbox_factory=lambda data: RichSandbox(fake_client, data), + ) + fake_client.sandboxes = client + 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 = client.create(template_name="my-tpl") + + assert isinstance(result, RichSandbox) + assert result.id == "sbx-1" + + def test_create_validates_input(self, mock_transport): + with pytest.raises(Leap0Error, match="memory_mib"): + SandboxesClient(mock_transport, sandbox_domain="s.dev").create(memory_mib=513) + + +class TestCreateSandboxParams: + def test_default_template_is_bookworm(self): + assert CreateSandboxParams().template_name == "system/debian:bookworm" + + +class TestRichSandbox: + def test_bound_service_methods_pass_sandbox(self): + process = MagicMock() + process.execute.return_value = MagicMock(stdout="Python 3.12") + sandboxes = MagicMock() + client = SimpleNamespace( + filesystem=MagicMock(), + git=MagicMock(), + process=process, + pty=MagicMock(), + lsp=MagicMock(), + ssh=MagicMock(), + code_interpreter=MagicMock(), + desktop=MagicMock(), + sandboxes=sandboxes, + ) + + sandbox = RichSandbox(client, Sandbox(id="sbx-1")) + result = sandbox.process.execute(command="python --version") + + process.execute.assert_called_once_with(sandbox, command="python --version") + assert result.stdout == "Python 3.12" + + def test_refresh_updates_metadata(self): + sandboxes = MagicMock() + client = SimpleNamespace( + filesystem=MagicMock(), + git=MagicMock(), + process=MagicMock(), + pty=MagicMock(), + lsp=MagicMock(), + ssh=MagicMock(), + code_interpreter=MagicMock(), + desktop=MagicMock(), + sandboxes=sandboxes, + ) + sandbox = RichSandbox(client, Sandbox(id="sbx-1", state="starting")) + sandboxes.get.return_value = RichSandbox(client, Sandbox(id="sbx-1", state="running")) + + sandbox.refresh() + + assert sandbox.state == "running" diff --git a/tests/test_snapshots.py b/tests/_sync/test_snapshots.py similarity index 66% rename from tests/test_snapshots.py rename to tests/_sync/test_snapshots.py index cd0c327..3497b79 100644 --- a/tests/test_snapshots.py +++ b/tests/_sync/test_snapshots.py @@ -2,8 +2,11 @@ from unittest.mock import MagicMock -from leap0.snapshots import SnapshotsClient -from leap0.common.snapshot import Snapshot +import pytest + +from leap0.models.errors import Leap0Error +from leap0._sync.snapshots import SnapshotsClient +from leap0.models.snapshot import ResumeSnapshotParams, Snapshot class TestSnapshotsClient: @@ -26,3 +29,13 @@ 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] + + def test_resume_validates_input(self, mock_transport): + with pytest.raises(Leap0Error, match="snapshot_name"): + SnapshotsClient(mock_transport).resume(snapshot_name=" ") + + +class TestResumeSnapshotParams: + def test_payload_trims_snapshot_name(self): + payload = ResumeSnapshotParams(snapshot_name=" snap-1 ").to_payload() + assert payload["snapshot_name"] == "snap-1" diff --git a/tests/test_ssh.py b/tests/_sync/test_ssh.py similarity index 94% rename from tests/test_ssh.py rename to tests/_sync/test_ssh.py index 71911bd..05e50be 100644 --- a/tests/test_ssh.py +++ b/tests/_sync/test_ssh.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from leap0.ssh import SshClient +from leap0._sync.ssh import SshClient class TestSshClient: diff --git a/tests/test_templates.py b/tests/_sync/test_templates.py similarity index 63% rename from tests/test_templates.py rename to tests/_sync/test_templates.py index 311dc16..67014f0 100644 --- a/tests/test_templates.py +++ b/tests/_sync/test_templates.py @@ -2,7 +2,11 @@ from unittest.mock import MagicMock -from leap0.templates import TemplatesClient +import pytest + +from leap0.models.errors import Leap0Error +from leap0._sync.templates import TemplatesClient +from leap0.models.template import CreateTemplateParams class TestTemplatesClient: @@ -27,3 +31,14 @@ 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 + + def test_create_validates_name(self, mock_transport): + with pytest.raises(Leap0Error, match="system/"): + TemplatesClient(mock_transport).create(name="system/bad", uri="docker.io/library/python:3.12") + + +class TestCreateTemplateParams: + def test_payload_trims_values(self): + payload = CreateTemplateParams(name=" my-template ", uri=" docker.io/library/python:3.12 ").to_payload() + assert payload["name"] == "my-template" + assert payload["uri"] == "docker.io/library/python:3.12" diff --git a/tests/test_transport.py b/tests/_sync/test_transport.py similarity index 91% rename from tests/test_transport.py rename to tests/_sync/test_transport.py index fcc0a71..b7945aa 100644 --- a/tests/test_transport.py +++ b/tests/_sync/test_transport.py @@ -6,8 +6,8 @@ import httpx import pytest -from leap0._transport import Transport -from leap0.common.errors import ( +from leap0._sync._transport import Transport +from leap0.models.errors import ( Leap0ConflictError, Leap0Error, Leap0NotFoundError, Leap0PermissionError, Leap0RateLimitError, ) @@ -25,7 +25,11 @@ def test_bearer_disabled(self): class TestHeaders: def test_default(self, transport): - assert transport.headers() == {"authorization": "Bearer test-key"} + headers = transport.headers() + assert headers["authorization"] == "Bearer test-key" + assert headers["Leap0-Source"] == "sdk-python" + assert headers["Leap0-SDK-Version"] + assert headers["User-Agent"].startswith("leap0-python/") def test_custom_auth_header(self): t = Transport(api_key="key", base_url="https://x.com", auth_header="leap0-authorization") diff --git a/tests/common/__init__.py b/tests/common/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/conftest.py b/tests/conftest.py index 41c2462..69762fc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,11 @@ from __future__ import annotations -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest -from leap0._transport import Transport +from leap0._sync._transport import Transport +from leap0._async._transport import AsyncTransport @pytest.fixture @@ -18,3 +19,17 @@ def mock_transport(): t.auth_header = "authorization" t.auth_value = "Bearer test-key" return t + + +@pytest.fixture +def async_mock_transport(): + t = MagicMock(spec=AsyncTransport) + t.auth_header = "authorization" + t.auth_value = "Bearer test-key" + t.request = AsyncMock() + t.request_json = AsyncMock() + t.request_target = AsyncMock() + t.request_target_json = AsyncMock() + t.stream = AsyncMock() + t.close = AsyncMock() + return t diff --git a/tests/models/__init__.py b/tests/models/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/tests/models/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/common/test_code_interpreter.py b/tests/models/test_code_interpreter.py similarity index 99% rename from tests/common/test_code_interpreter.py rename to tests/models/test_code_interpreter.py index cd60d5e..72ff09e 100644 --- a/tests/common/test_code_interpreter.py +++ b/tests/models/test_code_interpreter.py @@ -2,7 +2,7 @@ import base64 -from leap0.common.code_interpreter import ( +from leap0.models.code_interpreter import ( CodeContext, CodeExecutionError, CodeExecutionOutput, CodeExecutionResult, ExecutionLogs, StreamEvent, ) diff --git a/tests/common/test_config.py b/tests/models/test_config.py similarity index 95% rename from tests/common/test_config.py rename to tests/models/test_config.py index 9c0b713..15b2b70 100644 --- a/tests/common/test_config.py +++ b/tests/models/test_config.py @@ -5,8 +5,8 @@ import pytest -from leap0.common.config import Leap0Config -from leap0.client import Leap0Client +from leap0.models.config import Leap0Config +from leap0._sync.client import Leap0Client class TestLeap0Config: diff --git a/tests/common/test_desktop.py b/tests/models/test_desktop.py similarity index 98% rename from tests/common/test_desktop.py rename to tests/models/test_desktop.py index f666684..2a1b475 100644 --- a/tests/common/test_desktop.py +++ b/tests/models/test_desktop.py @@ -1,6 +1,6 @@ from __future__ import annotations -from leap0.common.desktop import ( +from leap0.models.desktop import ( DesktopDisplayInfo, DesktopHealth, DesktopPointerPosition, DesktopProcessErrors, DesktopProcessLogs, DesktopProcessRestart, DesktopProcessStatus, DesktopProcessStatusList, DesktopRecordingStatus, DesktopRecordingSummary, DesktopWindow, diff --git a/tests/common/test_errors.py b/tests/models/test_errors.py similarity index 67% rename from tests/common/test_errors.py rename to tests/models/test_errors.py index de41de1..3f0046a 100644 --- a/tests/common/test_errors.py +++ b/tests/models/test_errors.py @@ -3,7 +3,7 @@ import httpx import pytest -from leap0.common.errors import Leap0Error, Leap0NotFoundError, Leap0TimeoutError +from leap0.models.errors import Leap0Error, Leap0NotFoundError, Leap0TimeoutError from leap0._utils.errors import intercept_errors @@ -17,6 +17,18 @@ def failing(): failing() assert exc_info.value.message.startswith("Failed to delete file: ") + def test_sdk_error_is_recreated_not_mutated(self): + original = Leap0NotFoundError("Request failed", 404, body='{"message":"not found"}') + + @intercept_errors("Failed to delete file: ") + def failing(): + raise original + + with pytest.raises(Leap0NotFoundError) as exc_info: + failing() + assert exc_info.value is not original + assert original.message == "Request failed" + def test_httpx_timeout(self): @intercept_errors("Failed to read file: ") def failing(): @@ -51,6 +63,15 @@ def failing(): failing() assert not exc_info.value.message.startswith("Failed to write file: Failed to write file: ") + def test_closed_client_runtime_error(self): + @intercept_errors("Failed to list directory: ") + def failing(): + raise RuntimeError("Cannot send a request, as the client has been closed.") + + with pytest.raises(Leap0Error) as exc_info: + failing() + assert "client is closed" in exc_info.value.message + def test_success_passes_through(self): @intercept_errors("Nope: ") def ok(): diff --git a/tests/common/test_filesystem.py b/tests/models/test_filesystem.py similarity index 98% rename from tests/common/test_filesystem.py rename to tests/models/test_filesystem.py index 944a92e..c24cc7d 100644 --- a/tests/common/test_filesystem.py +++ b/tests/models/test_filesystem.py @@ -1,6 +1,6 @@ from __future__ import annotations -from leap0.common.filesystem import ( +from leap0.models.filesystem import ( EditFileResult, EditResult, FileEdit, FileInfo, LsResult, SearchMatch, TreeEntry, TreeResult, ) diff --git a/tests/common/test_git.py b/tests/models/test_git.py similarity index 93% rename from tests/common/test_git.py rename to tests/models/test_git.py index 55cfcd9..078594f 100644 --- a/tests/common/test_git.py +++ b/tests/models/test_git.py @@ -1,6 +1,6 @@ from __future__ import annotations -from leap0.common.git import GitCommitResult, GitResult +from leap0.models.git import GitCommitResult, GitResult class TestGitResult: diff --git a/tests/common/test_lsp.py b/tests/models/test_lsp.py similarity index 93% rename from tests/common/test_lsp.py rename to tests/models/test_lsp.py index 6a19e1b..48bc58d 100644 --- a/tests/common/test_lsp.py +++ b/tests/models/test_lsp.py @@ -1,6 +1,6 @@ from __future__ import annotations -from leap0.common.lsp import LspJsonRpcResponse, LspResponse +from leap0.models.lsp import LspJsonRpcResponse, LspResponse class TestLspResponse: diff --git a/tests/common/test_process.py b/tests/models/test_process.py similarity index 84% rename from tests/common/test_process.py rename to tests/models/test_process.py index d0fe7f9..667a3f0 100644 --- a/tests/common/test_process.py +++ b/tests/models/test_process.py @@ -1,6 +1,6 @@ from __future__ import annotations -from leap0.common.process import ProcessResult +from leap0.models.process import ProcessResult class TestProcessResult: diff --git a/tests/common/test_pty.py b/tests/models/test_pty.py similarity index 96% rename from tests/common/test_pty.py rename to tests/models/test_pty.py index 9eefd1c..2f3fd53 100644 --- a/tests/common/test_pty.py +++ b/tests/models/test_pty.py @@ -1,6 +1,6 @@ from __future__ import annotations -from leap0.common.pty import PtySession +from leap0.models.pty import PtySession class TestPtySession: diff --git a/tests/common/test_sandbox.py b/tests/models/test_sandbox.py similarity index 96% rename from tests/common/test_sandbox.py rename to tests/models/test_sandbox.py index a527014..435d221 100644 --- a/tests/common/test_sandbox.py +++ b/tests/models/test_sandbox.py @@ -1,6 +1,6 @@ from __future__ import annotations -from leap0.common.sandbox import Sandbox, SandboxStatus, sandbox_id_of +from leap0.models.sandbox import Sandbox, SandboxStatus, sandbox_id_of class TestSandboxIdOf: diff --git a/tests/common/test_snapshot.py b/tests/models/test_snapshot.py similarity index 94% rename from tests/common/test_snapshot.py rename to tests/models/test_snapshot.py index 4e3bd40..89b3949 100644 --- a/tests/common/test_snapshot.py +++ b/tests/models/test_snapshot.py @@ -1,6 +1,6 @@ from __future__ import annotations -from leap0.common.snapshot import Snapshot, snapshot_id_of +from leap0.models.snapshot import Snapshot, snapshot_id_of class TestSnapshotIdOf: diff --git a/tests/common/test_ssh.py b/tests/models/test_ssh.py similarity index 92% rename from tests/common/test_ssh.py rename to tests/models/test_ssh.py index 3873cc9..eed5856 100644 --- a/tests/common/test_ssh.py +++ b/tests/models/test_ssh.py @@ -1,6 +1,6 @@ from __future__ import annotations -from leap0.common.ssh import SshAccess, SshValidation +from leap0.models.ssh import SshAccess, SshValidation class TestSshAccess: diff --git a/tests/common/test_template.py b/tests/models/test_template.py similarity index 95% rename from tests/common/test_template.py rename to tests/models/test_template.py index b2e8613..9a0a4dd 100644 --- a/tests/common/test_template.py +++ b/tests/models/test_template.py @@ -1,6 +1,6 @@ from __future__ import annotations -from leap0.common.template import ImageConfig, Template +from leap0.models.template import ImageConfig, Template class TestImageConfig: diff --git a/tests/test_docstrings.py b/tests/test_docstrings.py new file mode 100644 index 0000000..b70e950 --- /dev/null +++ b/tests/test_docstrings.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import ast +from pathlib import Path + + +SDK_ROOT = Path(__file__).resolve().parents[1] / "leap0" +STRICT_SECTION_DIRS = {"_sync", "_async"} + + +def _iter_missing_public_docstrings() -> list[str]: + missing: list[str] = [] + for path in SDK_ROOT.rglob("*.py"): + if path.name == "__init__.py": + continue + tree = ast.parse(path.read_text(), filename=str(path)) + rel = path.relative_to(SDK_ROOT) + + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and not node.name.startswith("_"): + if not ast.get_docstring(node): + missing.append(f"{rel}:{node.name}") + + if isinstance(node, ast.ClassDef): + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and not item.name.startswith("_"): + if not ast.get_docstring(item): + missing.append(f"{rel}:{node.name}.{item.name}") + return missing + + +def _iter_docstring_section_failures() -> list[str]: + failures: list[str] = [] + for path in SDK_ROOT.rglob("*.py"): + if path.name == "__init__.py": + continue + tree = ast.parse(path.read_text(), filename=str(path)) + rel = path.relative_to(SDK_ROOT) + if rel.parts[0] not in STRICT_SECTION_DIRS: + continue + + for node in tree.body: + if isinstance(node, ast.ClassDef) and not node.name.startswith("_"): + doc = ast.get_docstring(node) or "" + if rel.parts[0] in STRICT_SECTION_DIRS and "Attributes:" not in doc: + failures.append(f"{rel}:{node.name} missing Attributes") + + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and not item.name.startswith("_"): + doc = ast.get_docstring(item) or "" + args = [a.arg for a in item.args.args + item.args.kwonlyargs if a.arg != "self" and a.arg != "cls"] + has_return = not isinstance(item, ast.FunctionDef) or True + if args and "Args:" not in doc: + failures.append(f"{rel}:{node.name}.{item.name} missing Args") + returns_none = False + if item.returns is None: + returns_none = True + elif isinstance(item.returns, ast.Constant) and item.returns.value is None: + returns_none = True + if not returns_none and "Returns:" not in doc and "Yields:" not in doc: + failures.append(f"{rel}:{node.name}.{item.name} missing Returns/Yields") + + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and not node.name.startswith("_"): + doc = ast.get_docstring(node) or "" + args = [a.arg for a in node.args.args + node.args.kwonlyargs] + if args and "Args:" not in doc: + failures.append(f"{rel}:{node.name} missing Args") + returns_none = False + if node.returns is None: + returns_none = True + elif isinstance(node.returns, ast.Constant) and node.returns.value is None: + returns_none = True + if not returns_none and "Returns:" not in doc and "Yields:" not in doc: + failures.append(f"{rel}:{node.name} missing Returns/Yields") + return failures + + +def test_public_sdk_apis_have_docstrings() -> None: + missing = _iter_missing_public_docstrings() + assert missing == [], "Missing docstrings:\n" + "\n".join(missing) + + +def test_public_sdk_docstrings_have_expected_sections() -> None: + failures = _iter_docstring_section_failures() + assert failures == [], "Docstring section failures:\n" + "\n".join(failures) diff --git a/tests/test_filesystem.py b/tests/test_filesystem.py deleted file mode 100644 index 1527a1d..0000000 --- a/tests/test_filesystem.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -from unittest.mock import MagicMock - -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): - 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_import.py b/tests/test_import.py index c83415a..e18814c 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -1,4 +1,38 @@ -from leap0 import FilesystemClient, GitClient, Leap0Client, LspClient, ProcessClient, PtyClient, SandboxesClient, SnapshotsClient, TemplatesClient +from leap0 import ( + AsyncLeap0, + AsyncLeap0Client, + AsyncCodeInterpreterClient, + AsyncDesktopClient, + AsyncFilesystemClient, + AsyncGitClient, + AsyncLspClient, + AsyncProcessClient, + AsyncPtyClient, + AsyncPtyConnection, + AsyncSandbox, + AsyncSandboxesClient, + AsyncSnapshotsClient, + AsyncSshClient, + AsyncTemplatesClient, + CreatePtySessionParams, + CreateSandboxParams, + CreateSnapshotParams, + CreateTemplateParams, + DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME, + DEFAULT_TEMPLATE_NAME, + FilesystemClient, + GitClient, + Leap0Client, + LspClient, + ProcessClient, + PtyClient, + RenameTemplateParams, + ResumeSnapshotParams, + Sandbox, + SandboxesClient, + SnapshotsClient, + TemplatesClient, +) def test_client_import() -> None: @@ -12,6 +46,38 @@ def test_service_client_imports() -> None: assert LspClient is not None assert ProcessClient is not None assert PtyClient is not None + assert Sandbox is not None assert SandboxesClient is not None assert SnapshotsClient is not None assert TemplatesClient is not None + assert AsyncLeap0 is not None + assert AsyncLeap0Client is not None + assert AsyncCodeInterpreterClient is not None + assert AsyncDesktopClient is not None + assert AsyncFilesystemClient is not None + assert AsyncGitClient is not None + assert AsyncLspClient is not None + assert AsyncProcessClient is not None + assert AsyncPtyClient is not None + assert AsyncSandbox is not None + assert AsyncSandboxesClient is not None + assert AsyncSnapshotsClient is not None + assert AsyncSshClient is not None + assert AsyncTemplatesClient is not None + assert AsyncPtyConnection is not None + assert CreateSandboxParams is not None + assert CreateSnapshotParams is not None + assert ResumeSnapshotParams is not None + assert CreateTemplateParams is not None + assert RenameTemplateParams is not None + assert CreatePtySessionParams is not None + assert DEFAULT_TEMPLATE_NAME == "system/debian:bookworm" + assert DEFAULT_CODE_INTERPRETER_TEMPLATE_NAME == "system/code-interpreter:v0.1.0" + + +def test_package_layout_imports() -> None: + from leap0._async import AsyncLeap0Client as PackageAsyncLeap0Client + from leap0._sync import Leap0Client as PackageLeap0Client + + assert PackageLeap0Client is Leap0Client + assert PackageAsyncLeap0Client is AsyncLeap0Client diff --git a/tests/test_sandboxes.py b/tests/test_sandboxes.py deleted file mode 100644 index 46a912d..0000000 --- a/tests/test_sandboxes.py +++ /dev/null @@ -1,43 +0,0 @@ -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" From b5e6ec8a55405a90203e502923274b9de5f51cde Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Wed, 1 Apr 2026 13:49:24 -0400 Subject: [PATCH 2/6] fxied comments --- examples/code_interpreter_stream.py | 6 +- examples/desktop.py | 6 +- examples/pty.py | 6 +- leap0/__init__.py | 1 + leap0/_async/_transport.py | 3 +- leap0/_async/client.py | 29 +++++-- leap0/_async/code_interpreter.py | 45 +++------- leap0/_async/desktop.py | 2 +- leap0/_async/filesystem.py | 57 +++++++++---- leap0/_async/git.py | 18 ++-- leap0/_async/lsp.py | 11 ++- leap0/_async/process.py | 2 +- leap0/_async/pty.py | 29 ++++--- leap0/_async/sandbox.py | 9 +- leap0/_async/ssh.py | 4 +- leap0/_schemas/snapshot.py | 6 +- leap0/_sync/_transport.py | 25 ++++-- leap0/_sync/client.py | 29 +++++-- leap0/_sync/desktop.py | 2 +- leap0/_sync/filesystem.py | 39 +++++++-- leap0/_sync/git.py | 33 ++------ leap0/_sync/lsp.py | 11 ++- leap0/_sync/process.py | 2 +- leap0/_sync/pty.py | 18 ++-- leap0/_sync/sandbox.py | 9 +- leap0/_sync/snapshots.py | 2 +- leap0/_sync/ssh.py | 12 +-- leap0/_utils/encoding.py | 4 +- leap0/_utils/errors.py | 125 +++++++++++++++++++++------- leap0/_utils/otel.py | 119 ++++++++++++++++++-------- tests/test_docstrings.py | 1 - 31 files changed, 450 insertions(+), 215 deletions(-) diff --git a/examples/code_interpreter_stream.py b/examples/code_interpreter_stream.py index d7b38f1..0ab28b9 100644 --- a/examples/code_interpreter_stream.py +++ b/examples/code_interpreter_stream.py @@ -20,8 +20,10 @@ def main() -> None: ): print(event) finally: - sandbox.delete() - client.close() + try: + sandbox.delete() + finally: + client.close() if __name__ == "__main__": diff --git a/examples/desktop.py b/examples/desktop.py index 5ce53ad..9def69e 100644 --- a/examples/desktop.py +++ b/examples/desktop.py @@ -17,9 +17,10 @@ def main() -> None: client = Leap0(Leap0Config()) - sandbox: Sandbox = client.sandboxes.create(template_name=DEFAULT_DESKTOP_TEMPLATE_NAME) + sandbox: Sandbox | None = None try: + sandbox = client.sandboxes.create(template_name=DEFAULT_DESKTOP_TEMPLATE_NAME) sandbox.desktop.wait_until_ready(timeout=60.0) print("Desktop:", sandbox.desktop.desktop_url()) @@ -34,7 +35,8 @@ def main() -> None: print("Saved screenshot to desktop-screenshot.png") finally: try: - sandbox.delete() + if sandbox is not None: + sandbox.delete() except Leap0Error: pass finally: diff --git a/examples/pty.py b/examples/pty.py index 47feda2..c0ad940 100644 --- a/examples/pty.py +++ b/examples/pty.py @@ -25,8 +25,10 @@ def main() -> None: finally: connection.close() finally: - sandbox.delete() - client.close() + try: + sandbox.delete() + finally: + client.close() if __name__ == "__main__": diff --git a/leap0/__init__.py b/leap0/__init__.py index 118eee9..9b694b1 100644 --- a/leap0/__init__.py +++ b/leap0/__init__.py @@ -260,6 +260,7 @@ "SshValidation": (".models.ssh", "SshValidation"), "CreateTemplateParams": (".models.template", "CreateTemplateParams"), "ImageConfig": (".models.template", "ImageConfig"), + "RegistryCredentialsDict": (".models.template", "RegistryCredentialsDict"), "Template": (".models.template", "Template"), "RenameTemplateParams": (".models.template", "RenameTemplateParams"), "CodeContext": (".models.code_interpreter", "CodeContext"), diff --git a/leap0/_async/_transport.py b/leap0/_async/_transport.py index afbd4bb..9bceb0a 100644 --- a/leap0/_async/_transport.py +++ b/leap0/_async/_transport.py @@ -155,13 +155,12 @@ async def _stream( timeout: float | None = None, ) -> httpx.Response: effective = timeout if timeout is not None else (self._timeout_override.get() or self.timeout) - timeout_dict = {"connect": effective, "read": effective, "write": effective, "pool": effective} request = self._client.build_request( method, self._target_url(target), json=json, headers=self.headers(), - extensions={"timeout": timeout_dict}, + timeout=httpx.Timeout(effective), ) response = await self._client.send(request, stream=True) if response.status_code >= 400: diff --git a/leap0/_async/client.py b/leap0/_async/client.py index cc66b7f..0ab14df 100644 --- a/leap0/_async/client.py +++ b/leap0/_async/client.py @@ -95,6 +95,8 @@ def __init__( auth_header=config.auth_header, bearer=config.bearer, ) + self._owns_tracer_provider = False + self._owns_meter_provider = False self.sandboxes: AsyncSandboxesClient[AsyncSandbox] = AsyncSandboxesClient( self._transport, sandbox_domain=config.sandbox_domain, @@ -118,17 +120,30 @@ def __init__( self._init_otel() def _init_otel(self) -> None: + self._owns_tracer_provider = False + self._owns_meter_provider = False resource = Resource.create( { service_attributes.SERVICE_NAME: "leap0-python-sdk", service_attributes.SERVICE_VERSION: SDK_VERSION, } ) - self._tracer_provider = TracerProvider(resource=resource) - self._tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) - trace.set_tracer_provider(self._tracer_provider) - self._meter_provider = MeterProvider(resource=resource) - metrics.set_meter_provider(self._meter_provider) + current_tracer_provider = trace.get_tracer_provider() + if not isinstance(current_tracer_provider, TracerProvider): + self._tracer_provider = TracerProvider(resource=resource) + self._tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + trace.set_tracer_provider(self._tracer_provider) + self._owns_tracer_provider = True + else: + self._tracer_provider = current_tracer_provider + + current_meter_provider = metrics.get_meter_provider() + if not isinstance(current_meter_provider, MeterProvider): + self._meter_provider = MeterProvider(resource=resource) + metrics.set_meter_provider(self._meter_provider) + self._owns_meter_provider = True + else: + self._meter_provider = current_meter_provider @with_instrumentation("async_client.get_sandbox") async def get_sandbox(self, sandbox_id: str) -> AsyncSandbox: @@ -158,9 +173,9 @@ async def create_sandbox(self, **kwargs: object) -> AsyncSandbox: async def close(self) -> None: """Close the client and release resources.""" await self._transport.close() - if self._tracer_provider is not None: + if self._owns_tracer_provider and self._tracer_provider is not None: self._tracer_provider.shutdown() - if self._meter_provider is not None: + if self._owns_meter_provider and self._meter_provider is not None: self._meter_provider.shutdown() async def __aenter__(self) -> AsyncLeap0Client: diff --git a/leap0/_async/code_interpreter.py b/leap0/_async/code_interpreter.py index 803453a..df06e72 100644 --- a/leap0/_async/code_interpreter.py +++ b/leap0/_async/code_interpreter.py @@ -83,20 +83,15 @@ async def health(self, sandbox: SandboxRef) -> bool: @intercept_errors("Failed to create execution context: ") async def create_context(self, sandbox: SandboxRef, *, language: str = "python", cwd: str | None = None, http_timeout: float | None = None) -> CodeContext: """Create a new execution context. - - Args: - sandbox: Sandbox ID or object. - language: Language runtime (e.g. ``"python"``, ``"typescript"``). - cwd: Working directory (default ``"/home/user"``). - - http_timeout: Optional HTTP request timeout in seconds for this SDK call. Args: - http_timeout: Optional HTTP request timeout in seconds for this SDK call. + sandbox: Sandbox ID or object. + language: Language runtime (e.g. ``"python"``, ``"typescript"``). + cwd: Working directory (default ``"/home/user"``). + http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: - CodeContext: Newly created persistent execution context. - + CodeContext: Newly created persistent execution context. """ payload: JsonObject = {"language": language} if cwd is not None: @@ -156,32 +151,18 @@ async def execute( http_timeout: float | None = None, ) -> CodeExecutionResult: """Execute code and wait for the full result. - - Args: - sandbox: Sandbox ID or object. - code: Source code to execute. - language: Language runtime (default ``"python"``). - context_id: Link to an existing context to share state. - Auto-generated if omitted. - env_vars: Environment variables for the execution. - timeout_ms: Execution timeout in milliseconds (default 30000). - - http_timeout: Optional HTTP request timeout in seconds for this SDK call. Args: - sandbox: Sandbox ID or object. - code: Source code to execute. - language: Language runtime (default ``"python"``). - context_id: Link to an existing context to share state. - timeout_ms: Execution timeout in milliseconds (default 30000). - - Yields: - StreamEvent: Streaming stdout, stderr, exit, and error events. - http_timeout: Optional HTTP request timeout in seconds for this SDK call. + sandbox: Sandbox ID or object. + code: Source code to execute. + language: Language runtime (default ``"python"``). + context_id: Link to an existing context to share state. Auto-generated if omitted. + env_vars: Environment variables for the execution. + timeout_ms: Execution timeout in milliseconds (default 30000). + http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: - CodeExecutionResult: Structured execution output, errors, and logs. - + CodeExecutionResult: Structured execution output, errors, and logs. """ payload: JsonObject = {"code": code, "language": language} if context_id is not None: diff --git a/leap0/_async/desktop.py b/leap0/_async/desktop.py index e26ffd4..e830d41 100644 --- a/leap0/_async/desktop.py +++ b/leap0/_async/desktop.py @@ -585,7 +585,7 @@ async def wait_until_ready(self, sandbox: SandboxRef, *, timeout: float = 60.0, last_error: Exception | None = None while time.monotonic() < deadline: try: - async for status in self.status_stream(sandbox, deadline=deadline): + async for status in self.status_stream(sandbox, deadline=deadline, http_timeout=http_timeout): if status.status == "running": return raise Leap0Error("Desktop status stream ended without reaching 'running' state") diff --git a/leap0/_async/filesystem.py b/leap0/_async/filesystem.py index 35e80f7..505b4df 100644 --- a/leap0/_async/filesystem.py +++ b/leap0/_async/filesystem.py @@ -133,7 +133,13 @@ async def write_file(self, sandbox: SandboxRef, *, path: str, content: str, enco ) ``` """ - await self.write_bytes(sandbox, path=path, content=content.encode(encoding), permissions=permissions) + await self.write_bytes( + sandbox, + path=path, + content=content.encode(encoding), + permissions=permissions, + http_timeout=http_timeout, + ) @intercept_errors("Failed to write files: ") async def write_files_bytes(self, sandbox: SandboxRef, *, files: dict[str, bytes], http_timeout: float | None = None) -> None: @@ -170,7 +176,11 @@ async def write_files(self, sandbox: SandboxRef, *, files: dict[str, str], encod encoding: Text encoding used before upload. http_timeout: Optional HTTP request timeout in seconds for this SDK call. """ - await self.write_files_bytes(sandbox, files={p: c.encode(encoding) for p, c in files.items()}) + await self.write_files_bytes( + sandbox, + files={p: c.encode(encoding) for p, c in files.items()}, + http_timeout=http_timeout, + ) @intercept_errors("Failed to read file: ") async def read_bytes( @@ -258,17 +268,26 @@ async def read_file( Returns: object: Result returned by this operation. """ - return (await self.read_bytes( - sandbox, - path=path, - offset=offset, - limit=limit, - head=head, - tail=tail, - )).decode(encoding) + return ( + await self.read_bytes( + sandbox, + path=path, + offset=offset, + limit=limit, + head=head, + tail=tail, + http_timeout=http_timeout, + ) + ).decode(encoding) @intercept_errors("Failed to read files: ") - async def read_files_bytes(self, sandbox: SandboxRef, *, paths: list[str]) -> dict[str, bytes]: + async def read_files_bytes( + self, + sandbox: SandboxRef, + *, + paths: list[str], + http_timeout: float | None = None, + ) -> dict[str, bytes]: """Read multiple files and return raw bytes keyed by path. Args: @@ -278,7 +297,12 @@ async def read_files_bytes(self, sandbox: SandboxRef, *, paths: list[str]) -> di Returns: object: Result returned by this operation. """ - response = await self._transport.request("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/read-files", json={"paths": paths}) + response = await self._transport.request( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/read-files", + json={"paths": paths}, + timeout=http_timeout, + ) return _parse_multipart_response(response.headers.get("content-type", ""), response.content) @intercept_errors("Failed to read files: ") @@ -292,7 +316,12 @@ async def read_files(self, sandbox: SandboxRef, *, paths: list[str], encoding: s Returns: object: Result returned by this operation. """ - return {path: content.decode(encoding) for path, content in (await self.read_files_bytes(sandbox, paths=paths)).items()} + return { + path: content.decode(encoding) + for path, content in ( + await self.read_files_bytes(sandbox, paths=paths, http_timeout=http_timeout) + ).items() + } @intercept_errors("Failed to delete: ") async def delete(self, sandbox: SandboxRef, *, path: str, recursive: bool = False, http_timeout: float | None = None) -> None: @@ -499,7 +528,7 @@ async def tree(self, sandbox: SandboxRef, *, path: str, max_depth: int | None = return TreeResult.from_dict(data) -async def _parse_multipart_response(content_type: str, body: bytes) -> dict[str, bytes]: +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 diff --git a/leap0/_async/git.py b/leap0/_async/git.py index 8698833..88e533a 100644 --- a/leap0/_async/git.py +++ b/leap0/_async/git.py @@ -134,7 +134,7 @@ async def diff_unstaged(self, sandbox: SandboxRef, *, path: str, context_lines: payload: JsonObject = {"path": path} if context_lines is not None: payload["context_lines"] = context_lines - return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff-unstaged", payload) + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff-unstaged", payload, http_timeout=http_timeout) @intercept_errors("Failed to get staged diff: ") async def diff_staged(self, sandbox: SandboxRef, *, path: str, context_lines: int | None = None, http_timeout: float | None = None) -> GitResult: @@ -150,7 +150,7 @@ async def diff_staged(self, sandbox: SandboxRef, *, path: str, context_lines: in payload: JsonObject = {"path": path} if context_lines is not None: payload["context_lines"] = context_lines - return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff-staged", payload) + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff-staged", payload, http_timeout=http_timeout) @intercept_errors("Failed to get diff: ") async def diff(self, sandbox: SandboxRef, *, path: str, target: str, context_lines: int | None = None, http_timeout: float | None = None) -> GitResult: @@ -166,7 +166,7 @@ async def diff(self, sandbox: SandboxRef, *, path: str, target: str, context_lin payload: JsonObject = {"path": path, "target": target} if context_lines is not None: payload["context_lines"] = context_lines - return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff", payload) + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff", payload, http_timeout=http_timeout) @intercept_errors("Failed to reset: ") async def reset(self, sandbox: SandboxRef, *, path: str) -> GitResult: @@ -212,7 +212,7 @@ async def log( payload["start_timestamp"] = start_timestamp if end_timestamp is not None: payload["end_timestamp"] = end_timestamp - return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/log", payload) + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/log", payload, http_timeout=http_timeout) @intercept_errors("Failed to show revision: ") async def show(self, sandbox: SandboxRef, *, path: str, revision: str = "HEAD") -> GitResult: @@ -257,7 +257,7 @@ async def create_branch( payload["checkout"] = checkout if base_branch is not None: payload["base_branch"] = base_branch - return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/create-branch", payload) + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/create-branch", payload, http_timeout=http_timeout) @intercept_errors("Failed to checkout branch: ") async def checkout_branch(self, sandbox: SandboxRef, *, path: str, branch: str, create: bool | None = None, http_timeout: float | None = None) -> GitResult: @@ -273,7 +273,7 @@ async def checkout_branch(self, sandbox: SandboxRef, *, path: str, branch: str, payload: JsonObject = {"path": path, "branch": branch} if create is not None: payload["create"] = create - return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/checkout-branch", payload) + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/checkout-branch", payload, http_timeout=http_timeout) @intercept_errors("Failed to delete branch: ") async def delete_branch(self, sandbox: SandboxRef, *, path: str, name: str, force: bool = False) -> GitResult: @@ -301,7 +301,7 @@ async def add(self, sandbox: SandboxRef, *, path: str, files: list[str], http_ti Returns: object: Result returned by this operation. """ - return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/add", {"path": path, "files": files}) + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/add", {"path": path, "files": files}, http_timeout=http_timeout) @intercept_errors("Failed to commit: ") async def commit( @@ -394,7 +394,7 @@ async def push( payload["username"] = username if password is not None: payload["password"] = password - return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/push", payload) + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/push", payload, http_timeout=http_timeout) @intercept_errors("Failed to pull: ") async def pull( @@ -439,4 +439,4 @@ async def pull( payload["username"] = username if password is not None: payload["password"] = password - return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/pull", payload) + return await self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/pull", payload, http_timeout=http_timeout) diff --git a/leap0/_async/lsp.py b/leap0/_async/lsp.py index 9151aa1..7a006ce 100644 --- a/leap0/_async/lsp.py +++ b/leap0/_async/lsp.py @@ -132,7 +132,15 @@ async def did_open_path( await self.did_open(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path), text=text, version=version, http_timeout=http_timeout) @intercept_errors("Failed to close document: ") - async def did_close(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str, uri: str) -> None: + async def did_close( + self, + sandbox: SandboxRef, + *, + language_id: str, + path_to_project: str, + uri: str, + http_timeout: float | None = None, + ) -> None: """Notify the language server that a document was closed. Args: @@ -146,6 +154,7 @@ async def did_close(self, sandbox: SandboxRef, *, language_id: str, path_to_proj f"/v1/sandbox/{sandbox_id_of(sandbox)}/lsp/did-close", json={"language_id": language_id, "path_to_project": path_to_project, "uri": uri}, expected_status=204, + timeout=http_timeout, ) @intercept_errors("Failed to close document: ") diff --git a/leap0/_async/process.py b/leap0/_async/process.py index 92a9b89..d91c9ce 100644 --- a/leap0/_async/process.py +++ b/leap0/_async/process.py @@ -40,7 +40,7 @@ async def execute(self, sandbox: SandboxRef, *, command: str, cwd: str | None = Example: ```python - result = client.process.execute( + result = await client.process.execute( sandbox, command="ls -la /workspace", ) diff --git a/leap0/_async/pty.py b/leap0/_async/pty.py index 306f466..0dea91f 100644 --- a/leap0/_async/pty.py +++ b/leap0/_async/pty.py @@ -1,5 +1,6 @@ from __future__ import annotations +from urllib.parse import quote from typing import Any, cast from websockets.asyncio.client import ClientConnection, connect @@ -96,11 +97,6 @@ async def create( lazy_start: Defer shell start until the first WebSocket connection. http_timeout: Optional HTTP request timeout in seconds for this SDK call. - Args: - sandbox: Sandbox ID or object. - session_id: PTY session identifier. - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - Returns: PtySession: Metadata for the created PTY session. """ @@ -132,7 +128,8 @@ async def get(self, sandbox: SandboxRef, session_id: str) -> PtySession: Returns: object: Result returned by this operation. """ - data = cast(PtySessionInfoDict, await self._transport.request_json("GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}")) + encoded_session_id = quote(session_id, safe="") + data = cast(PtySessionInfoDict, await self._transport.request_json("GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{encoded_session_id}")) return PtySession.from_dict(data) @intercept_errors("Failed to delete PTY session: ") @@ -144,7 +141,8 @@ async def delete(self, sandbox: SandboxRef, session_id: str, http_timeout: float session_id: PTY session identifier. http_timeout: Optional HTTP request timeout in seconds for this SDK call. """ - await self._transport.request("DELETE", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}", expected_status=204, timeout=http_timeout) + encoded_session_id = quote(session_id, safe="") + await self._transport.request("DELETE", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{encoded_session_id}", expected_status=204, timeout=http_timeout) @intercept_errors("Failed to resize PTY session: ") async def resize(self, sandbox: SandboxRef, session_id: str, *, cols: int, rows: int) -> PtySession: @@ -159,10 +157,12 @@ async def resize(self, sandbox: SandboxRef, session_id: str, *, cols: int, rows: Returns: object: Result returned by this operation. """ + payload = CreatePtySessionParams(cols=cols, rows=rows).to_payload() + encoded_session_id = quote(session_id, safe="") data = cast(PtySessionInfoDict, await self._transport.request_json( "POST", - f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}/resize", - json={"cols": cols, "rows": rows}, + f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{encoded_session_id}/resize", + json=payload, )) return PtySession.from_dict(data) @@ -176,12 +176,17 @@ def websocket_url(self, sandbox: SandboxRef, session_id: str) -> str: Returns: object: Result returned by this operation. """ - return websocket_url_from_http(f"{self._transport.base_url}/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}/connect") + encoded_session_id = quote(session_id, safe="") + return websocket_url_from_http(f"{self._transport.base_url}/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{encoded_session_id}/connect") @intercept_errors("Failed to connect to PTY session: ") async def connect(self, sandbox: SandboxRef, session_id: str, http_timeout: float | None = None, **kwargs: Any) -> AsyncPtyConnection: """Open a WebSocket connection for interactive terminal I/O. + Important: + Callers are responsible for closing the returned connection, ideally + with ``try/finally`` or an async context manager wrapper. + Args: sandbox: Sandbox ID or object. session_id: PTY session identifier. @@ -194,7 +199,9 @@ async def connect(self, sandbox: SandboxRef, session_id: str, http_timeout: floa url = self.websocket_url(sandbox, session_id) if http_timeout is not None and "open_timeout" not in kwargs: kwargs["open_timeout"] = http_timeout - websocket = await connect(url, additional_headers={self._transport.auth_header: self._transport.auth_value}, **kwargs) + additional_headers = dict(kwargs.pop("additional_headers", {}) or {}) + additional_headers[self._transport.auth_header] = self._transport.auth_value + websocket = await connect(url, additional_headers=additional_headers, **kwargs) return AsyncPtyConnection(websocket) diff --git a/leap0/_async/sandbox.py b/leap0/_async/sandbox.py index 92016e6..58331d2 100644 --- a/leap0/_async/sandbox.py +++ b/leap0/_async/sandbox.py @@ -246,13 +246,18 @@ async def get(self, sandbox: SandboxRef, http_timeout: float | None = None) -> A return self._wrap_sandbox(SandboxStatus.from_dict(data)) @intercept_errors("Failed to delete sandbox: ") - async def delete(self, sandbox: SandboxRef) -> None: + async def delete(self, sandbox: SandboxRef, http_timeout: float | None = None) -> None: """Terminate and delete a sandbox. Args: sandbox: Sandbox ID or object. """ - await self._transport.request("DELETE", f"/v1/sandbox/{sandbox_id_of(sandbox)}/", expected_status=204) + await self._transport.request( + "DELETE", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/", + expected_status=204, + timeout=http_timeout, + ) def invoke_url(self, sandbox: SandboxRef, path: str = "/", *, port: int | None = None) -> str: """Build an HTTPS URL for this sandbox. diff --git a/leap0/_async/ssh.py b/leap0/_async/ssh.py index a934ff5..28f9773 100644 --- a/leap0/_async/ssh.py +++ b/leap0/_async/ssh.py @@ -17,8 +17,8 @@ class AsyncSshClient: Example: ```python - sandbox = client.sandboxes.create() - access = sandbox.ssh.create_access() + sandbox = await client.sandboxes.create() + access = await sandbox.ssh.create_access() print(access.command) ``` diff --git a/leap0/_schemas/snapshot.py b/leap0/_schemas/snapshot.py index 3f77a14..57af70d 100644 --- a/leap0/_schemas/snapshot.py +++ b/leap0/_schemas/snapshot.py @@ -1,6 +1,10 @@ from __future__ import annotations -from typing import TypedDict +from typing import TYPE_CHECKING, TypedDict + +if TYPE_CHECKING: + from .._schemas.sandbox import NetworkPolicyDict + from ..models.sandbox import SandboxState class SnapshotCreateResponseDict(TypedDict, total=False): """Wire schema for snapshot creation responses.""" diff --git a/leap0/_sync/_transport.py b/leap0/_sync/_transport.py index 239408d..9f5a155 100644 --- a/leap0/_sync/_transport.py +++ b/leap0/_sync/_transport.py @@ -151,13 +151,12 @@ def _stream( timeout: float | None = None, ) -> httpx.Response: effective = timeout if timeout is not None else (self._timeout_override.get() or self.timeout) - timeout_dict = {"connect": effective, "read": effective, "write": effective, "pool": effective} request = self._client.build_request( method, self._target_url(target), json=json, headers=self.headers(), - extensions={"timeout": timeout_dict}, + timeout=httpx.Timeout(effective), ) response = self._client.send(request, stream=True) if response.status_code >= 400: @@ -207,7 +206,7 @@ def request( content=content, files=files, headers=actual_headers, - timeout=timeout or self.timeout, + timeout=timeout or self._timeout_override.get() or self.timeout, ) return self._check_response(response, method, path, expected_status) @@ -260,6 +259,7 @@ def request_target( params: JsonObject | None = None, json: JsonObject | None = None, expected_status: int | tuple[int, ...] = 200, + timeout: float | None = None, ) -> httpx.Response: """Send a request to an absolute URL (e.g. sandbox-domain URLs). @@ -273,7 +273,14 @@ def request_target( Returns: object: Result returned by this operation. """ - return self._request(method, target, params=params, json=json, expected_status=expected_status) + return self._request( + method, + target, + params=params, + json=json, + expected_status=expected_status, + timeout=timeout, + ) @with_instrumentation("transport.request_target_json") def request_target_json( @@ -284,6 +291,7 @@ def request_target_json( params: JsonObject | None = None, json: JsonObject | None = None, expected_status: int | tuple[int, ...] = 200, + timeout: float | None = None, ) -> JsonObject: """Send a request to an absolute URL and return parsed JSON. @@ -297,7 +305,14 @@ def request_target_json( Returns: object: Result returned by this operation. """ - resp = self._request(method, target, params=params, json=json, expected_status=expected_status) + resp = self._request( + method, + target, + params=params, + json=json, + expected_status=expected_status, + timeout=timeout, + ) return resp.json() def stream(self, method: str, target: str, *, json: JsonObject | None = None, timeout: float | None = None) -> httpx.Response: diff --git a/leap0/_sync/client.py b/leap0/_sync/client.py index c21b65c..e20dff7 100644 --- a/leap0/_sync/client.py +++ b/leap0/_sync/client.py @@ -104,6 +104,8 @@ def __init__( auth_header=config.auth_header, bearer=config.bearer, ) + self._owns_tracer_provider = False + self._owns_meter_provider = False self.sandboxes: SandboxesClient[Sandbox] = SandboxesClient( self._transport, sandbox_domain=config.sandbox_domain, @@ -127,17 +129,30 @@ def __init__( self._init_otel() def _init_otel(self) -> None: + self._owns_tracer_provider = False + self._owns_meter_provider = False resource = Resource.create( { service_attributes.SERVICE_NAME: "leap0-python-sdk", service_attributes.SERVICE_VERSION: self._transport.headers().get("Leap0-SDK-Version", "unknown"), } ) - self._tracer_provider = TracerProvider(resource=resource) - self._tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) - trace.set_tracer_provider(self._tracer_provider) - self._meter_provider = MeterProvider(resource=resource) - metrics.set_meter_provider(self._meter_provider) + current_tracer_provider = trace.get_tracer_provider() + if not isinstance(current_tracer_provider, TracerProvider): + self._tracer_provider = TracerProvider(resource=resource) + self._tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + trace.set_tracer_provider(self._tracer_provider) + self._owns_tracer_provider = True + else: + self._tracer_provider = current_tracer_provider + + current_meter_provider = metrics.get_meter_provider() + if not isinstance(current_meter_provider, MeterProvider): + self._meter_provider = MeterProvider(resource=resource) + metrics.set_meter_provider(self._meter_provider) + self._owns_meter_provider = True + else: + self._meter_provider = current_meter_provider @with_instrumentation("client.get_sandbox") def get_sandbox(self, sandbox_id: str) -> Sandbox: @@ -171,9 +186,9 @@ def close(self) -> None: context manager. """ self._transport.close() - if self._tracer_provider is not None: + if self._owns_tracer_provider and self._tracer_provider is not None: self._tracer_provider.shutdown() - if self._meter_provider is not None: + if self._owns_meter_provider and self._meter_provider is not None: self._meter_provider.shutdown() def __enter__(self) -> Self: diff --git a/leap0/_sync/desktop.py b/leap0/_sync/desktop.py index e51a2bb..91b1b6d 100644 --- a/leap0/_sync/desktop.py +++ b/leap0/_sync/desktop.py @@ -634,7 +634,7 @@ def _is_transient_leap0(exc: BaseException) -> bool: reraise=True, ) def _poll() -> None: - for status in self.status_stream(sandbox, deadline=deadline): + for status in self.status_stream(sandbox, deadline=deadline, http_timeout=http_timeout): if status.status == "running": return raise Leap0Error("Desktop status stream ended without reaching 'running' state") diff --git a/leap0/_sync/filesystem.py b/leap0/_sync/filesystem.py index a1fd0a2..31938ce 100644 --- a/leap0/_sync/filesystem.py +++ b/leap0/_sync/filesystem.py @@ -133,7 +133,13 @@ def write_file(self, sandbox: SandboxRef, *, path: str, content: str, encoding: ) ``` """ - self.write_bytes(sandbox, path=path, content=content.encode(encoding), permissions=permissions) + self.write_bytes( + sandbox, + path=path, + content=content.encode(encoding), + permissions=permissions, + http_timeout=http_timeout, + ) @intercept_errors("Failed to write files: ") def write_files_bytes(self, sandbox: SandboxRef, *, files: dict[str, bytes], http_timeout: float | None = None) -> None: @@ -170,7 +176,11 @@ def write_files(self, sandbox: SandboxRef, *, files: dict[str, str], encoding: s encoding: Text encoding used before upload. http_timeout: Optional HTTP request timeout in seconds for this SDK call. """ - self.write_files_bytes(sandbox, files={p: c.encode(encoding) for p, c in files.items()}) + self.write_files_bytes( + sandbox, + files={p: c.encode(encoding) for p, c in files.items()}, + http_timeout=http_timeout, + ) @intercept_errors("Failed to read file: ") def read_bytes( @@ -265,10 +275,17 @@ def read_file( limit=limit, head=head, tail=tail, + http_timeout=http_timeout, ).decode(encoding) @intercept_errors("Failed to read files: ") - def read_files_bytes(self, sandbox: SandboxRef, *, paths: list[str]) -> dict[str, bytes]: + def read_files_bytes( + self, + sandbox: SandboxRef, + *, + paths: list[str], + http_timeout: float | None = None, + ) -> dict[str, bytes]: """Read multiple files and return raw bytes keyed by path. Args: @@ -278,7 +295,12 @@ def read_files_bytes(self, sandbox: SandboxRef, *, paths: list[str]) -> dict[str Returns: object: Result returned by this operation. """ - response = self._transport.request("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/read-files", json={"paths": paths}) + response = self._transport.request( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/read-files", + json={"paths": paths}, + timeout=http_timeout, + ) return _parse_multipart_response(response.headers.get("content-type", ""), response.content) @intercept_errors("Failed to read files: ") @@ -292,7 +314,14 @@ def read_files(self, sandbox: SandboxRef, *, paths: list[str], encoding: str = " Returns: object: Result returned by this operation. """ - return {path: content.decode(encoding) for path, content in self.read_files_bytes(sandbox, paths=paths).items()} + return { + path: content.decode(encoding) + for path, content in self.read_files_bytes( + sandbox, + paths=paths, + http_timeout=http_timeout, + ).items() + } @intercept_errors("Failed to delete: ") def delete(self, sandbox: SandboxRef, *, path: str, recursive: bool = False, http_timeout: float | None = None) -> None: diff --git a/leap0/_sync/git.py b/leap0/_sync/git.py index 59c500e..999c669 100644 --- a/leap0/_sync/git.py +++ b/leap0/_sync/git.py @@ -53,10 +53,6 @@ def clone( depth: Shallow clone depth. username: Auth username (for private repos). password: Auth password or token (for private repos). - - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - - Args: http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: @@ -134,7 +130,7 @@ def diff_unstaged(self, sandbox: SandboxRef, *, path: str, context_lines: int | payload: JsonObject = {"path": path} if context_lines is not None: payload["context_lines"] = context_lines - return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff-unstaged", payload) + return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff-unstaged", payload, http_timeout=http_timeout) @intercept_errors("Failed to get staged diff: ") def diff_staged(self, sandbox: SandboxRef, *, path: str, context_lines: int | None = None, http_timeout: float | None = None) -> GitResult: @@ -150,7 +146,7 @@ def diff_staged(self, sandbox: SandboxRef, *, path: str, context_lines: int | No payload: JsonObject = {"path": path} if context_lines is not None: payload["context_lines"] = context_lines - return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff-staged", payload) + return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff-staged", payload, http_timeout=http_timeout) @intercept_errors("Failed to get diff: ") def diff(self, sandbox: SandboxRef, *, path: str, target: str, context_lines: int | None = None, http_timeout: float | None = None) -> GitResult: @@ -166,7 +162,7 @@ def diff(self, sandbox: SandboxRef, *, path: str, target: str, context_lines: in payload: JsonObject = {"path": path, "target": target} if context_lines is not None: payload["context_lines"] = context_lines - return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff", payload) + return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff", payload, http_timeout=http_timeout) @intercept_errors("Failed to reset: ") def reset(self, sandbox: SandboxRef, *, path: str) -> GitResult: @@ -212,7 +208,7 @@ def log( payload["start_timestamp"] = start_timestamp if end_timestamp is not None: payload["end_timestamp"] = end_timestamp - return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/log", payload) + return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/log", payload, http_timeout=http_timeout) @intercept_errors("Failed to show revision: ") def show(self, sandbox: SandboxRef, *, path: str, revision: str = "HEAD") -> GitResult: @@ -257,7 +253,7 @@ def create_branch( payload["checkout"] = checkout if base_branch is not None: payload["base_branch"] = base_branch - return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/create-branch", payload) + return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/create-branch", payload, http_timeout=http_timeout) @intercept_errors("Failed to checkout branch: ") def checkout_branch(self, sandbox: SandboxRef, *, path: str, branch: str, create: bool | None = None, http_timeout: float | None = None) -> GitResult: @@ -273,7 +269,7 @@ def checkout_branch(self, sandbox: SandboxRef, *, path: str, branch: str, create payload: JsonObject = {"path": path, "branch": branch} if create is not None: payload["create"] = create - return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/checkout-branch", payload) + return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/checkout-branch", payload, http_timeout=http_timeout) @intercept_errors("Failed to delete branch: ") def delete_branch(self, sandbox: SandboxRef, *, path: str, name: str, force: bool = False) -> GitResult: @@ -301,7 +297,7 @@ def add(self, sandbox: SandboxRef, *, path: str, files: list[str], http_timeout: Returns: object: Result returned by this operation. """ - return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/add", {"path": path, "files": files}) + return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/add", {"path": path, "files": files}, http_timeout=http_timeout) @intercept_errors("Failed to commit: ") def commit( @@ -324,17 +320,6 @@ def commit( author: Author name. email: Author email. allow_empty: Allow creating an empty commit. - - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - - Args: - sandbox: Sandbox ID or object. - path: Path to the git repo. - remote: Remote name (default ``"origin"``). - branch: Branch name. - set_upstream: Set upstream tracking. - username: Auth username. - password: Auth password or token. http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: @@ -394,7 +379,7 @@ def push( payload["username"] = username if password is not None: payload["password"] = password - return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/push", payload) + return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/push", payload, http_timeout=http_timeout) @intercept_errors("Failed to pull: ") def pull( @@ -439,4 +424,4 @@ def pull( payload["username"] = username if password is not None: payload["password"] = password - return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/pull", payload) + return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/pull", payload, http_timeout=http_timeout) diff --git a/leap0/_sync/lsp.py b/leap0/_sync/lsp.py index 15c0b6c..e2204f5 100644 --- a/leap0/_sync/lsp.py +++ b/leap0/_sync/lsp.py @@ -132,7 +132,15 @@ def did_open_path( self.did_open(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path), text=text, version=version, http_timeout=http_timeout) @intercept_errors("Failed to close document: ") - def did_close(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str, uri: str) -> None: + def did_close( + self, + sandbox: SandboxRef, + *, + language_id: str, + path_to_project: str, + uri: str, + http_timeout: float | None = None, + ) -> None: """Notify the language server that a document was closed. Args: @@ -146,6 +154,7 @@ def did_close(self, sandbox: SandboxRef, *, language_id: str, path_to_project: s f"/v1/sandbox/{sandbox_id_of(sandbox)}/lsp/did-close", json={"language_id": language_id, "path_to_project": path_to_project, "uri": uri}, expected_status=204, + timeout=http_timeout, ) @intercept_errors("Failed to close document: ") diff --git a/leap0/_sync/process.py b/leap0/_sync/process.py index 038f9af..6d5abf0 100644 --- a/leap0/_sync/process.py +++ b/leap0/_sync/process.py @@ -33,7 +33,7 @@ def execute(self, sandbox: SandboxRef, *, command: str, cwd: str | None = None, sandbox: Sandbox ID or object. command: Shell command to execute. cwd: Working directory. - timeout: Timeout in seconds (default 30). + timeout: Timeout in seconds. If omitted, the server-side default is used. Returns: ProcessResult: Command result including exit code, stdout, and stderr. diff --git a/leap0/_sync/pty.py b/leap0/_sync/pty.py index 1dccace..b17dde3 100644 --- a/leap0/_sync/pty.py +++ b/leap0/_sync/pty.py @@ -1,5 +1,6 @@ from __future__ import annotations +from urllib.parse import quote from typing import Any, cast from websockets.sync.client import connect @@ -105,7 +106,8 @@ def get(self, sandbox: SandboxRef, session_id: str) -> PtySession: Returns: object: Result returned by this operation. """ - data = cast(PtySessionInfoDict, self._transport.request_json("GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}")) + encoded_session_id = quote(session_id, safe="") + data = cast(PtySessionInfoDict, self._transport.request_json("GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{encoded_session_id}")) return PtySession.from_dict(data) @intercept_errors("Failed to delete PTY session: ") @@ -117,7 +119,8 @@ def delete(self, sandbox: SandboxRef, session_id: str, http_timeout: float | Non session_id: PTY session identifier. http_timeout: Optional HTTP request timeout in seconds for this SDK call. """ - self._transport.request("DELETE", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}", expected_status=204, timeout=http_timeout) + encoded_session_id = quote(session_id, safe="") + self._transport.request("DELETE", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{encoded_session_id}", expected_status=204, timeout=http_timeout) @intercept_errors("Failed to resize PTY session: ") def resize(self, sandbox: SandboxRef, session_id: str, *, cols: int, rows: int) -> PtySession: @@ -132,7 +135,9 @@ def resize(self, sandbox: SandboxRef, session_id: str, *, cols: int, rows: int) Returns: object: Result returned by this operation. """ - data = cast(PtySessionInfoDict, self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}/resize", json={"cols": cols, "rows": rows})) + payload = CreatePtySessionParams(cols=cols, rows=rows).to_payload() + encoded_session_id = quote(session_id, safe="") + data = cast(PtySessionInfoDict, self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{encoded_session_id}/resize", json=payload)) return PtySession.from_dict(data) def websocket_url(self, sandbox: SandboxRef, session_id: str) -> str: @@ -145,7 +150,8 @@ def websocket_url(self, sandbox: SandboxRef, session_id: str) -> str: Returns: object: Result returned by this operation. """ - return websocket_url_from_http(f"{self._transport.base_url}/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{session_id}/connect") + encoded_session_id = quote(session_id, safe="") + return websocket_url_from_http(f"{self._transport.base_url}/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{encoded_session_id}/connect") @intercept_errors("Failed to connect to PTY session: ") def connect(self, sandbox: SandboxRef, session_id: str, http_timeout: float | None = None, **kwargs: Any) -> PtyConnection: @@ -167,5 +173,7 @@ def connect(self, sandbox: SandboxRef, session_id: str, http_timeout: float | No url = self.websocket_url(sandbox, session_id) if http_timeout is not None and "open_timeout" not in kwargs: kwargs["open_timeout"] = http_timeout - websocket = connect(url, additional_headers={self._transport.auth_header: self._transport.auth_value}, **kwargs) + additional_headers = dict(kwargs.pop("additional_headers", {}) or {}) + additional_headers[self._transport.auth_header] = self._transport.auth_value + websocket = connect(url, additional_headers=additional_headers, **kwargs) return PtyConnection(websocket=websocket) diff --git a/leap0/_sync/sandbox.py b/leap0/_sync/sandbox.py index 951f5d7..d9adc4d 100644 --- a/leap0/_sync/sandbox.py +++ b/leap0/_sync/sandbox.py @@ -261,13 +261,18 @@ def get(self, sandbox: SandboxRef, http_timeout: float | None = None) -> Sandbox return self._wrap_sandbox(SandboxStatus.from_dict(data)) @intercept_errors("Failed to delete sandbox: ") - def delete(self, sandbox: SandboxRef) -> None: + def delete(self, sandbox: SandboxRef, http_timeout: float | None = None) -> None: """Terminate and delete a sandbox. Args: sandbox: Sandbox ID or object. """ - self._transport.request("DELETE", f"/v1/sandbox/{sandbox_id_of(sandbox)}/", expected_status=204) + self._transport.request( + "DELETE", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/", + expected_status=204, + timeout=http_timeout, + ) def invoke_url(self, sandbox: SandboxRef, path: str = "/", *, port: int | None = None) -> str: """Build an HTTPS URL that routes directly to the sandbox. diff --git a/leap0/_sync/snapshots.py b/leap0/_sync/snapshots.py index 4dd2ab2..c96ef44 100644 --- a/leap0/_sync/snapshots.py +++ b/leap0/_sync/snapshots.py @@ -6,7 +6,7 @@ from .._internal.types import SandboxFactory from .._utils.errors import intercept_errors from ..models.sandbox import Sandbox, SandboxRef, sandbox_id_of -from .._schemas.sandbox import SandboxCreateResponseDict +from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict from ..models.snapshot import CreateSnapshotParams, ResumeSnapshotParams, Snapshot, SnapshotRef, snapshot_id_of from .._schemas.snapshot import SnapshotCreateResponseDict diff --git a/leap0/_sync/ssh.py b/leap0/_sync/ssh.py index a430af4..652e928 100644 --- a/leap0/_sync/ssh.py +++ b/leap0/_sync/ssh.py @@ -80,13 +80,13 @@ def validate_access(self, sandbox: SandboxRef, *, access_id: str, password: str, @intercept_errors("Failed to regenerate SSH access: ") def regenerate_access(self, sandbox: SandboxRef, http_timeout: float | None = None) -> SshAccess: """Invalidate the current credential and generate a new one. The expiry is also reset. - - Args: - sandbox: Sandbox ID or object. - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - + + Args: + sandbox: Sandbox ID or object. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Returns: - object: Result returned by this operation. + SshAccess: Newly generated SSH credential bundle. """ data = self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/ssh/regen", timeout=http_timeout) return SshAccess.from_dict(cast(dict, data)) diff --git a/leap0/_utils/encoding.py b/leap0/_utils/encoding.py index 1b51c4b..e8fbea4 100644 --- a/leap0/_utils/encoding.py +++ b/leap0/_utils/encoding.py @@ -9,12 +9,12 @@ def b64encode_bytes(value: bytes) -> str: def b64encode_text(value: str, encoding: str = "utf-8") -> str: - """Encode text as base64 using UTF-8.""" + """Encode text as base64 using the supplied encoding (UTF-8 by default).""" return b64encode_bytes(value.encode(encoding)) def b64decode_text(value: str, encoding: str = "utf-8") -> str: - """Decode a base64 string into UTF-8 text.""" + """Decode a base64 string into text using the supplied encoding.""" return base64.b64decode(value).decode(encoding) diff --git a/leap0/_utils/errors.py b/leap0/_utils/errors.py index 7e260a4..ebd7bac 100644 --- a/leap0/_utils/errors.py +++ b/leap0/_utils/errors.py @@ -2,11 +2,15 @@ import functools import inspect -from typing import Any, AsyncGenerator, Callable, Generator, Iterator, NoReturn, TypeVar, cast +from collections.abc import AsyncGenerator, AsyncIterator, Callable, Generator, Iterator +from contextlib import AbstractAsyncContextManager, AbstractContextManager +from typing import ParamSpec, NoReturn, TypeVar, cast from ..models.errors import Leap0Error, Leap0TimeoutError -F = TypeVar("F", bound=Callable[..., Any]) +P = ParamSpec("P") +R = TypeVar("R") +T = TypeVar("T") _HTTPX_CLOSED_CLIENT_MESSAGES = ( @@ -58,23 +62,45 @@ def _raise_processed(prefix: str, exc: Exception) -> NoReturn: raise Leap0Error(_prefixed_message(str(exc), prefix)) from exc -def _wrap_generator(gen: Iterator[Any], message_prefix: str) -> Generator[Any, Any, Any]: +def _wrap_generator( + gen: Iterator[T], + message_prefix: str, + transport: object | None = None, + http_timeout: float | None = None, +) -> Generator[T, None, None]: """Yield from *gen* while applying the same error-normalisation logic.""" try: - yield from gen + if transport is not None: + with _sync_timeout_override(transport, http_timeout): + yield from gen + else: + yield from gen except Exception as exc: _raise_processed(message_prefix, exc) -async def _wrap_async_generator(gen: Any, message_prefix: str) -> AsyncGenerator[Any, Any]: +async def _wrap_async_generator( + gen: AsyncIterator[T], + message_prefix: str, + transport: object | None = None, + http_timeout: float | None = None, +) -> AsyncGenerator[T, None]: try: - async for item in gen: - yield item + if transport is not None: + async with _async_timeout_override(transport, http_timeout): + async for item in gen: + yield item + else: + async for item in gen: + yield item except Exception as exc: _raise_processed(message_prefix, exc) -def _get_timeout_context(args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[Any | None, float | None]: +def _get_timeout_context( + args: tuple[object, ...], + kwargs: dict[str, object], +) -> tuple[object | None, float | None]: http_timeout = kwargs.get("http_timeout") if http_timeout is None or not args: return None, None @@ -84,8 +110,22 @@ def _get_timeout_context(args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple return transport, cast(float, http_timeout) +def _sync_timeout_override( + transport: object, + http_timeout: float | None, +) -> AbstractContextManager[object]: + return cast(AbstractContextManager[object], getattr(transport, "override_timeout")(http_timeout)) + + +def _async_timeout_override( + transport: object, + http_timeout: float | None, +) -> AbstractAsyncContextManager[object]: + return cast(AbstractAsyncContextManager[object], getattr(transport, "override_timeout")(http_timeout)) + + # Error interception decorator -def intercept_errors(message_prefix: str = "") -> Callable[[F], F]: +def intercept_errors(message_prefix: str = "") -> Callable[[Callable[P, R]], Callable[P, R]]: """Decorator that normalizes transport and runtime failures into SDK errors. The decorator turns low-level exceptions into fresh ``Leap0Error`` subclasses with a @@ -96,54 +136,75 @@ def intercept_errors(message_prefix: str = "") -> Callable[[F], F]: error handling also covers exceptions raised during iteration. """ - def decorator(fn: F) -> F: + def decorator(fn: Callable[P, R]) -> Callable[P, R]: if inspect.isasyncgenfunction(fn): @functools.wraps(fn) - async def async_gen_wrapper(*args: Any, **kwargs: Any) -> Any: + def async_gen_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: try: - transport, http_timeout = _get_timeout_context(args, kwargs) - if transport is not None: - async with transport.override_timeout(http_timeout): - result = fn(*args, **kwargs) - else: - result = fn(*args, **kwargs) + transport, http_timeout = _get_timeout_context( + cast(tuple[object, ...], args), + cast(dict[str, object], kwargs), + ) + result = fn(*args, **kwargs) except Exception as exc: - _raise_processed(message_prefix, cast(Exception, exc)) + _raise_processed(message_prefix, exc) else: - return _wrap_async_generator(result, message_prefix) - - return async_gen_wrapper # type: ignore[return-value] + return cast( + R, + _wrap_async_generator( + cast(AsyncIterator[object], result), + message_prefix, + transport, + http_timeout, + ), + ) + + return async_gen_wrapper if inspect.iscoroutinefunction(fn): @functools.wraps(fn) - async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: try: - transport, http_timeout = _get_timeout_context(args, kwargs) + transport, http_timeout = _get_timeout_context( + cast(tuple[object, ...], args), + cast(dict[str, object], kwargs), + ) if transport is not None: - async with transport.override_timeout(http_timeout): + async with _async_timeout_override(transport, http_timeout): return await fn(*args, **kwargs) return await fn(*args, **kwargs) except Exception as exc: - _raise_processed(message_prefix, cast(Exception, exc)) + _raise_processed(message_prefix, exc) - return async_wrapper # type: ignore[return-value] + return async_wrapper @functools.wraps(fn) - def wrapper(*args: Any, **kwargs: Any) -> Any: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: try: - transport, http_timeout = _get_timeout_context(args, kwargs) + transport, http_timeout = _get_timeout_context( + cast(tuple[object, ...], args), + cast(dict[str, object], kwargs), + ) if transport is not None: - with transport.override_timeout(http_timeout): + with _sync_timeout_override(transport, http_timeout): result = fn(*args, **kwargs) else: result = fn(*args, **kwargs) except Exception as exc: - _raise_processed(message_prefix, cast(Exception, exc)) + _raise_processed(message_prefix, exc) else: if isinstance(result, (Iterator, Generator)) or inspect.isgenerator(result): - return _wrap_generator(result, message_prefix) + return cast( + R, + _wrap_generator( + cast(Iterator[object], result), + message_prefix, + transport, + http_timeout, + ), + ) return result - return wrapper # type: ignore[return-value] + return wrapper return decorator diff --git a/leap0/_utils/otel.py b/leap0/_utils/otel.py index ba7aaf7..a7a96dc 100644 --- a/leap0/_utils/otel.py +++ b/leap0/_utils/otel.py @@ -2,57 +2,119 @@ import asyncio import functools +import threading import time -from typing import Any, Callable, TypeVar, cast +from collections.abc import Callable +from contextlib import AbstractContextManager +from typing import ParamSpec, Protocol, TypeVar, cast from opentelemetry import metrics, trace from opentelemetry.trace import Status, StatusCode -F = TypeVar("F", bound=Callable[..., Any]) +P = ParamSpec("P") +R = TypeVar("R") + + +class _HistogramProtocol(Protocol): + """Protocol for OpenTelemetry histograms used by the SDK.""" + + def record(self, amount: float, attributes: dict[str, str]) -> None: + """Record a duration value with metric attributes.""" + ... + + +class _MeterProtocol(Protocol): + """Protocol for OpenTelemetry meters used by the SDK.""" + + def create_histogram(self, name: str, *, description: str, unit: str) -> _HistogramProtocol: + """Create a histogram instrument.""" + ... + + +class _SpanProtocol(Protocol): + """Protocol for spans created by the SDK tracer.""" + + def set_status(self, status: Status) -> None: + """Set the status on the current span.""" + ... + + def record_exception(self, exception: BaseException) -> None: + """Attach an exception to the current span.""" + ... + + +class _SpanContextManagerProtocol(Protocol): + """Protocol for context managers that yield spans.""" + + def __enter__(self) -> _SpanProtocol: + """Enter the span context.""" + ... + + def __exit__(self, exc_type: object, exc: object, tb: object) -> bool | None: + """Exit the span context.""" + ... + + +class _TracerProtocol(Protocol): + """Protocol for the OpenTelemetry tracer used by the SDK.""" + + def start_as_current_span(self, name: str) -> AbstractContextManager[_SpanProtocol]: + """Create a context manager that activates a span.""" + ... _tracer = None _meter = None -_histograms: dict[str, Any] = {} +_histograms: dict[str, _HistogramProtocol] = {} +_histograms_lock = threading.Lock() -def get_tracer() -> Any: +def get_tracer() -> _TracerProtocol: """Return the SDK OpenTelemetry tracer singleton.""" global _tracer if _tracer is None: _tracer = trace.get_tracer("leap0-sdk-python") - return _tracer + return cast(_TracerProtocol, _tracer) -def get_meter() -> Any: +def get_meter() -> _MeterProtocol: """Return the SDK OpenTelemetry meter singleton.""" global _meter if _meter is None: _meter = metrics.get_meter("leap0-sdk-python") - return _meter + return cast(_MeterProtocol, _meter) def _metric_name(name: str) -> str: return name.replace(".", "_").lower() -def with_instrumentation(name: str) -> Callable[[F], F]: +def _get_histogram(name: str) -> _HistogramProtocol: + histogram_name = _metric_name(name) + histogram = _histograms.get(histogram_name) + if histogram is not None: + return histogram + + with _histograms_lock: + histogram = _histograms.get(histogram_name) + if histogram is None: + histogram = get_meter().create_histogram( + f"{histogram_name}_duration", + description=f"Duration of {name}", + unit="ms", + ) + _histograms[histogram_name] = histogram + return histogram + + +def with_instrumentation(name: str) -> Callable[[Callable[P, R]], Callable[P, R]]: """Instrument a function with OpenTelemetry spans and duration metrics.""" - def decorator(func: F) -> F: + def decorator(func: Callable[P, R]) -> Callable[P, R]: if asyncio.iscoroutinefunction(func): @functools.wraps(func) - async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: tracer = get_tracer() - meter = get_meter() - histogram_name = _metric_name(name) - histogram = _histograms.get(histogram_name) - if histogram is None: - histogram = meter.create_histogram( - f"{histogram_name}_duration", - description=f"Duration of {name}", - unit="ms", - ) - _histograms[histogram_name] = histogram + histogram = _get_histogram(name) start = time.time() with tracer.start_as_current_span(name) as span: @@ -67,21 +129,12 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: histogram.record((time.time() - start) * 1000, {"status": "error"}) raise - return cast(F, async_wrapper) + return cast(Callable[P, R], async_wrapper) @functools.wraps(func) - def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: tracer = get_tracer() - meter = get_meter() - histogram_name = _metric_name(name) - histogram = _histograms.get(histogram_name) - if histogram is None: - histogram = meter.create_histogram( - f"{histogram_name}_duration", - description=f"Duration of {name}", - unit="ms", - ) - _histograms[histogram_name] = histogram + histogram = _get_histogram(name) start = time.time() with tracer.start_as_current_span(name) as span: @@ -96,6 +149,6 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: histogram.record((time.time() - start) * 1000, {"status": "error"}) raise - return cast(F, sync_wrapper) + return cast(Callable[P, R], sync_wrapper) return decorator diff --git a/tests/test_docstrings.py b/tests/test_docstrings.py index b70e950..21ebf5c 100644 --- a/tests/test_docstrings.py +++ b/tests/test_docstrings.py @@ -49,7 +49,6 @@ def _iter_docstring_section_failures() -> list[str]: if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and not item.name.startswith("_"): doc = ast.get_docstring(item) or "" args = [a.arg for a in item.args.args + item.args.kwonlyargs if a.arg != "self" and a.arg != "cls"] - has_return = not isinstance(item, ast.FunctionDef) or True if args and "Args:" not in doc: failures.append(f"{rel}:{node.name}.{item.name} missing Args") returns_none = False From 5d4f66ac3353be6e76fa04324f644818679062e5 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Wed, 1 Apr 2026 14:28:54 -0400 Subject: [PATCH 3/6] fixed comments --- leap0/__init__.py | 8 +++++ leap0/_async/client.py | 54 +++++++++++++++++++------------- leap0/_async/code_interpreter.py | 11 +++---- leap0/_async/desktop.py | 34 +++++++++++++------- leap0/_async/filesystem.py | 27 +++++----------- leap0/_async/git.py | 6 ++-- leap0/_async/lsp.py | 8 ++--- leap0/_async/process.py | 8 ++--- leap0/_async/pty.py | 36 +++++++++++++++------ leap0/_async/sandbox.py | 17 +++++----- leap0/_async/snapshots.py | 6 ++-- leap0/_async/ssh.py | 4 +-- leap0/_async/templates.py | 4 +-- leap0/_sync/_transport.py | 2 +- leap0/_sync/client.py | 14 ++++++--- leap0/_sync/code_interpreter.py | 8 ++--- leap0/_sync/desktop.py | 31 +++++++++++------- leap0/_sync/filesystem.py | 27 +++++----------- leap0/_sync/git.py | 18 +++++------ leap0/_sync/lsp.py | 28 +++++++---------- leap0/_sync/process.py | 13 ++++---- leap0/_sync/pty.py | 8 ++--- leap0/_sync/sandbox.py | 8 ++--- leap0/_sync/snapshots.py | 6 ++-- leap0/_sync/ssh.py | 4 +-- leap0/_sync/templates.py | 4 +-- leap0/models/pty.py | 21 +++++++++++++ leap0/models/sandbox.py | 3 +- leap0/models/snapshot.py | 3 +- 29 files changed, 236 insertions(+), 185 deletions(-) diff --git a/leap0/__init__.py b/leap0/__init__.py index 9b694b1..8b8ce90 100644 --- a/leap0/__init__.py +++ b/leap0/__init__.py @@ -166,12 +166,14 @@ "LspJsonRpcError", "LspJsonRpcResponse", "LspResponse", + "NetworkPolicyMode", "ProcessClient", "ProcessResult", "PtyClient", "PtyConnection", "PtySession", "RenameTemplateParams", + "RegistryCredentialType", "RegistryCredentialsDict", "ResumeSnapshotParams", "Sandbox", @@ -180,11 +182,13 @@ "SandboxesClient", "SearchMatch", "Snapshot", + "snapshot_id_of", "SnapshotsClient", "SshAccess", "SshClient", "SshValidation", "StreamEvent", + "sandbox_id_of", "Template", "TemplatesClient", "TreeEntry", @@ -234,11 +238,14 @@ "Leap0TimeoutError": (".models.errors", "Leap0TimeoutError"), "Leap0WebSocketError": (".models.errors", "Leap0WebSocketError"), "CreateSandboxParams": (".models.sandbox", "CreateSandboxParams"), + "NetworkPolicyMode": (".models.sandbox", "NetworkPolicyMode"), "SandboxStatus": (".models.sandbox", "SandboxStatus"), "SandboxState": (".models.sandbox", "SandboxState"), + "sandbox_id_of": (".models.sandbox", "sandbox_id_of"), "CreateSnapshotParams": (".models.snapshot", "CreateSnapshotParams"), "ResumeSnapshotParams": (".models.snapshot", "ResumeSnapshotParams"), "Snapshot": (".models.snapshot", "Snapshot"), + "snapshot_id_of": (".models.snapshot", "snapshot_id_of"), "EditFileResult": (".models.filesystem", "EditFileResult"), "EditResult": (".models.filesystem", "EditResult"), "FileEdit": (".models.filesystem", "FileEdit"), @@ -260,6 +267,7 @@ "SshValidation": (".models.ssh", "SshValidation"), "CreateTemplateParams": (".models.template", "CreateTemplateParams"), "ImageConfig": (".models.template", "ImageConfig"), + "RegistryCredentialType": (".models.template", "RegistryCredentialType"), "RegistryCredentialsDict": (".models.template", "RegistryCredentialsDict"), "Template": (".models.template", "Template"), "RenameTemplateParams": (".models.template", "RenameTemplateParams"), diff --git a/leap0/_async/client.py b/leap0/_async/client.py index 0ab14df..5409eb1 100644 --- a/leap0/_async/client.py +++ b/leap0/_async/client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from types import TracebackType from opentelemetry import metrics, trace @@ -10,9 +11,7 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.semconv.attributes import service_attributes -from ._transport import AsyncTransport from .._internal.version import SDK_VERSION -from .._utils.otel import with_instrumentation from ..models.config import ( DEFAULT_BASE_URL, DEFAULT_CLIENT_TIMEOUT, @@ -25,6 +24,8 @@ DEFAULT_VCPU, Leap0Config, ) +from .._utils.otel import with_instrumentation +from ._transport import AsyncTransport from .code_interpreter import AsyncCodeInterpreterClient from .desktop import AsyncDesktopClient from .filesystem import AsyncFilesystemClient @@ -71,6 +72,7 @@ class AsyncLeap0Client: def __init__( self, *, + config: Leap0Config | None = None, api_key: str | None = None, base_url: str | None = None, sandbox_domain: str | None = None, @@ -79,15 +81,31 @@ def __init__( bearer: bool = True, otel_enabled: bool | None = None, ): - config = Leap0Config( - api_key=api_key, - base_url=base_url, - sandbox_domain=sandbox_domain, - timeout=timeout, - auth_header=auth_header, - bearer=bearer, - otel_enabled=otel_enabled, - ) + if config is not None: + provided_overrides = { + "api_key": api_key, + "base_url": base_url, + "sandbox_domain": sandbox_domain, + "auth_header": auth_header if auth_header != "authorization" else None, + "bearer": bearer if bearer is not True else None, + "otel_enabled": otel_enabled, + } + if timeout != DEFAULT_CLIENT_TIMEOUT: + provided_overrides["timeout"] = timeout + conflicting = [name for name, value in provided_overrides.items() if value is not None] + if conflicting: + joined = ", ".join(conflicting) + raise ValueError(f"Cannot pass config with individual overrides: {joined}") + if config is None: + config = Leap0Config( + api_key=api_key, + base_url=base_url, + sandbox_domain=sandbox_domain, + timeout=timeout, + auth_header=auth_header, + bearer=bearer, + otel_enabled=otel_enabled, + ) self._transport = AsyncTransport( api_key=config.api_key, base_url=config.base_url, @@ -174,9 +192,9 @@ async def close(self) -> None: """Close the client and release resources.""" await self._transport.close() if self._owns_tracer_provider and self._tracer_provider is not None: - self._tracer_provider.shutdown() + await asyncio.to_thread(self._tracer_provider.shutdown) if self._owns_meter_provider and self._meter_provider is not None: - self._meter_provider.shutdown() + await asyncio.to_thread(self._meter_provider.shutdown) async def __aenter__(self) -> AsyncLeap0Client: return self @@ -199,15 +217,7 @@ def AsyncLeap0(config: Leap0Config) -> AsyncLeap0Client: Returns: AsyncLeap0Client: Configured asynchronous client instance. """ - return AsyncLeap0Client( - api_key=config.api_key, - base_url=config.base_url, - sandbox_domain=config.sandbox_domain, - timeout=config.timeout, - auth_header=config.auth_header, - bearer=config.bearer, - otel_enabled=config.otel_enabled, - ) + return AsyncLeap0Client(config=config) __all__ = ["AsyncLeap0", "AsyncLeap0Client", "AsyncPtyConnection"] diff --git a/leap0/_async/code_interpreter.py b/leap0/_async/code_interpreter.py index df06e72..ae9b26d 100644 --- a/leap0/_async/code_interpreter.py +++ b/leap0/_async/code_interpreter.py @@ -6,10 +6,6 @@ import httpx from .._internal.types import JsonObject -from ._transport import AsyncTransport -from .._utils.errors import intercept_errors -from .._utils.stream import aiter_sse_events -from .._utils.url import sandbox_base_url from ..models.code_interpreter import ( CodeContext, CodeContextDict, @@ -19,6 +15,10 @@ StreamEventDict, ) from ..models.sandbox import SandboxRef, sandbox_id_of +from .._utils.errors import intercept_errors +from .._utils.stream import aiter_sse_events +from .._utils.url import sandbox_base_url +from ._transport import AsyncTransport class AsyncCodeInterpreterClient: @@ -171,8 +171,7 @@ async def execute( payload["env_vars"] = env_vars if timeout_ms is not None: payload["timeout_ms"] = timeout_ms - response = await self._request("POST", sandbox, "/execute", json=payload, http_timeout=http_timeout) - data = cast(CodeExecutionResultDict, response.json()) + data = cast(CodeExecutionResultDict, await self._request_json("POST", sandbox, "/execute", json=payload, http_timeout=http_timeout)) return CodeExecutionResult.from_dict(data) @intercept_errors("Failed to execute code: ") diff --git a/leap0/_async/desktop.py b/leap0/_async/desktop.py index e830d41..17e7790 100644 --- a/leap0/_async/desktop.py +++ b/leap0/_async/desktop.py @@ -3,16 +3,11 @@ import asyncio import time from collections.abc import AsyncIterator -from typing import Any, cast +from typing import cast import httpx from .._internal.types import JsonObject -from ._transport import AsyncTransport -from .._utils.errors import intercept_errors -from .._utils.stream import aiter_sse_events -from .._utils.url import sandbox_base_url -from ..models.errors import Leap0Error, Leap0TimeoutError from ..models.desktop import ( DesktopDisplayInfo, DesktopDisplayInfoDict, @@ -38,6 +33,11 @@ DesktopWindowsDict, ) from ..models.sandbox import SandboxRef, sandbox_id_of +from ..models.errors import Leap0Error, Leap0TimeoutError +from .._utils.errors import intercept_errors +from .._utils.stream import aiter_sse_events +from .._utils.url import sandbox_base_url +from ._transport import AsyncTransport class AsyncDesktopClient: @@ -316,17 +316,18 @@ async def scroll(self, sandbox: SandboxRef, *, direction: str, amount: int | Non return DesktopPointerPosition.from_dict(data) @intercept_errors("Failed to type text: ") - async def type_text(self, sandbox: SandboxRef, *, text: str) -> bool: + async def type_text(self, sandbox: SandboxRef, *, text: str, http_timeout: float | None = None) -> bool: """Type text through the desktop input service. Args: sandbox: Sandbox ID or object. - text: Parameter for this operation. + text: Text to type. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: object: Result returned by this operation. """ - data = await self._request_json("POST", sandbox, "/api/input/type", json={"text": text}) + data = await self._request_json("POST", sandbox, "/api/input/type", json={"text": text}, http_timeout=http_timeout) return bool(data.get("ok", False)) @intercept_errors("Failed to press key: ") @@ -549,11 +550,22 @@ async def status_stream(self, sandbox: SandboxRef, *, deadline: float | None = N object: Items yielded by this operation. """ url = f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}/api/status/stream" - response = await self._transport.stream("GET", url, timeout=http_timeout) + stream_timeout = http_timeout + if deadline is not None: + remaining_time = deadline - time.monotonic() + if remaining_time <= 0: + raise Leap0TimeoutError("Desktop status stream timed out") + stream_timeout = remaining_time if stream_timeout is None else min(stream_timeout, remaining_time) + response = await self._transport.stream("GET", url, timeout=stream_timeout) try: - async for event in aiter_sse_events(response.aiter_lines()): + events = aiter_sse_events(response.aiter_lines()) + while True: if deadline is not None and time.monotonic() >= deadline: raise Leap0TimeoutError("Desktop status stream timed out") + try: + event = await events.__anext__() + except StopAsyncIteration: + break if not isinstance(event, dict): continue if "error" in event: diff --git a/leap0/_async/filesystem.py b/leap0/_async/filesystem.py index 505b4df..d222ebf 100644 --- a/leap0/_async/filesystem.py +++ b/leap0/_async/filesystem.py @@ -3,11 +3,11 @@ from typing import Any, cast from .._internal.types import JsonObject -from ._transport import AsyncTransport -from .._utils.errors import intercept_errors from ..models.filesystem import EditFileResult, EditResult, FileEdit, FileInfo, LsResult, SearchMatch, TreeResult -from .._schemas.filesystem import EditFileResponseDict, EditFilesResponseDict, ExistsResponseDict, FileInfoDict, GlobResponseDict, GrepResponseDict, LsResponseDict, TreeResponseDict 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 ._transport import AsyncTransport class AsyncFilesystemClient: @@ -203,30 +203,16 @@ async def read_bytes( limit: Maximum bytes to read. head: Return only the first N lines (mutually exclusive with *tail*). tail: Return only the last N lines (mutually exclusive with *head*). - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - Args: - sandbox: Sandbox ID or object. - path: Path to the file. - offset: Byte offset to start from. - limit: Maximum bytes to read. - head: Return only the first N lines. - tail: Return only the last N lines. - encoding: Text encoding used to decode the response body. - - http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: - str: Decoded file contents. + bytes: File contents as raw bytes. Example: ```python - content = sandbox.filesystem.read_file(path="/workspace/README.md") + content = await sandbox.filesystem.read_bytes(path="/workspace/logo.png") print(content) ``` - - Returns: - bytes: File contents as raw bytes. """ payload: JsonObject = {"path": path} if offset is not None: @@ -450,7 +436,7 @@ async def edit_files(self, sandbox: SandboxRef, *, paths: list[str], find: str, return [EditResult.from_dict(item) for item in data.get("items", [])] @intercept_errors("Failed to move: ") - async def move(self, sandbox: SandboxRef, *, src_path: str, dst_path: str, overwrite: bool = False) -> None: + async def move(self, sandbox: SandboxRef, *, src_path: str, dst_path: str, overwrite: bool = False, http_timeout: float | None = None) -> None: """Move or rename a file or directory. Args: @@ -464,6 +450,7 @@ async def move(self, sandbox: SandboxRef, *, src_path: str, dst_path: str, overw f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/move", json={"src_path": src_path, "dst_path": dst_path, "overwrite": overwrite}, expected_status=204, + timeout=http_timeout, ) @intercept_errors("Failed to copy: ") diff --git a/leap0/_async/git.py b/leap0/_async/git.py index 88e533a..41b918d 100644 --- a/leap0/_async/git.py +++ b/leap0/_async/git.py @@ -3,11 +3,11 @@ from typing import cast from .._internal.types import JsonObject -from ._transport import AsyncTransport -from .._utils.errors import intercept_errors from ..models.git import GitCommitResult, GitResult -from .._schemas.git import GitCommitResponseDict, GitResultDict from ..models.sandbox import SandboxRef, sandbox_id_of +from .._schemas.git import GitCommitResponseDict, GitResultDict +from .._utils.errors import intercept_errors +from ._transport import AsyncTransport class AsyncGitClient: diff --git a/leap0/_async/lsp.py b/leap0/_async/lsp.py index 7a006ce..c453d30 100644 --- a/leap0/_async/lsp.py +++ b/leap0/_async/lsp.py @@ -3,12 +3,12 @@ from typing import cast from .._internal.types import JsonObject -from ._transport import AsyncTransport -from .._utils.errors import intercept_errors -from .._utils.url import file_uri as _file_uri from ..models.lsp import LspJsonRpcResponse, LspResponse -from .._schemas.lsp import LspJsonRpcResponseDict, LspSuccessResponseDict from ..models.sandbox import SandboxRef, sandbox_id_of +from .._schemas.lsp import LspJsonRpcResponseDict, LspSuccessResponseDict +from .._utils.errors import intercept_errors +from .._utils.url import file_uri as _file_uri +from ._transport import AsyncTransport class AsyncLspClient: diff --git a/leap0/_async/process.py b/leap0/_async/process.py index d91c9ce..5586bfc 100644 --- a/leap0/_async/process.py +++ b/leap0/_async/process.py @@ -1,13 +1,13 @@ from __future__ import annotations -from typing import Any, cast +from typing import cast from .._internal.types import JsonObject -from ._transport import AsyncTransport -from .._utils.errors import intercept_errors from ..models.process import ProcessResult -from .._schemas.process import ProcessResultDict from ..models.sandbox import SandboxRef, sandbox_id_of +from .._schemas.process import ProcessResultDict +from .._utils.errors import intercept_errors +from ._transport import AsyncTransport class AsyncProcessClient: diff --git a/leap0/_async/pty.py b/leap0/_async/pty.py index 0dea91f..e27021c 100644 --- a/leap0/_async/pty.py +++ b/leap0/_async/pty.py @@ -1,17 +1,18 @@ from __future__ import annotations +import warnings from urllib.parse import quote from typing import Any, cast from websockets.asyncio.client import ClientConnection, connect from .._internal.types import JsonObject -from ._transport import AsyncTransport +from ..models.pty import CreatePtySessionParams, PtyResizeParams, PtySession +from ..models.sandbox import SandboxRef, sandbox_id_of +from .._schemas.pty import PtyListResponseDict, PtySessionInfoDict from .._utils.errors import intercept_errors from .._utils.url import websocket_url_from_http -from ..models.pty import CreatePtySessionParams, PtySession -from .._schemas.pty import PtyListResponseDict, PtySessionInfoDict -from ..models.sandbox import SandboxRef, sandbox_id_of +from ._transport import AsyncTransport class AsyncPtyConnection: @@ -157,7 +158,7 @@ async def resize(self, sandbox: SandboxRef, session_id: str, *, cols: int, rows: Returns: object: Result returned by this operation. """ - payload = CreatePtySessionParams(cols=cols, rows=rows).to_payload() + payload = PtyResizeParams(cols=cols, rows=rows).to_payload() encoded_session_id = quote(session_id, safe="") data = cast(PtySessionInfoDict, await self._transport.request_json( "POST", @@ -180,7 +181,14 @@ def websocket_url(self, sandbox: SandboxRef, session_id: str) -> str: return websocket_url_from_http(f"{self._transport.base_url}/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{encoded_session_id}/connect") @intercept_errors("Failed to connect to PTY session: ") - async def connect(self, sandbox: SandboxRef, session_id: str, http_timeout: float | None = None, **kwargs: Any) -> AsyncPtyConnection: + async def connect( + self, + sandbox: SandboxRef, + session_id: str, + open_timeout: float | None = None, + http_timeout: float | None = None, + **kwargs: Any, + ) -> AsyncPtyConnection: """Open a WebSocket connection for interactive terminal I/O. Important: @@ -190,15 +198,25 @@ async def connect(self, sandbox: SandboxRef, session_id: str, http_timeout: floa Args: sandbox: Sandbox ID or object. session_id: PTY session identifier. - http_timeout: Optional WebSocket open timeout in seconds. + open_timeout: Optional WebSocket open timeout in seconds. + http_timeout: Deprecated alias for ``open_timeout``. **kwargs: Additional keyword arguments passed to ``websockets.asyncio.client.connect``. Returns: AsyncPtyConnection: Open WebSocket-backed PTY connection. """ url = self.websocket_url(sandbox, session_id) - if http_timeout is not None and "open_timeout" not in kwargs: - kwargs["open_timeout"] = http_timeout + if http_timeout is not None: + warnings.warn( + "http_timeout is deprecated for AsyncPtyClient.connect(); use open_timeout instead", + DeprecationWarning, + stacklevel=2, + ) + if open_timeout is not None and open_timeout != http_timeout: + raise ValueError("Received conflicting values for open_timeout and deprecated http_timeout") + open_timeout = http_timeout + if open_timeout is not None and "open_timeout" not in kwargs: + kwargs["open_timeout"] = open_timeout additional_headers = dict(kwargs.pop("additional_headers", {}) or {}) additional_headers[self._transport.auth_header] = self._transport.auth_value websocket = await connect(url, additional_headers=additional_headers, **kwargs) diff --git a/leap0/_async/sandbox.py b/leap0/_async/sandbox.py index 58331d2..9c0b154 100644 --- a/leap0/_async/sandbox.py +++ b/leap0/_async/sandbox.py @@ -2,18 +2,21 @@ import os from functools import wraps -from typing import Generic, Protocol, TypeVar, cast +from typing import TYPE_CHECKING, Generic, Protocol, TypeVar, cast -from ._transport import AsyncTransport from .._internal.types import SandboxFactory -from .._utils.errors import intercept_errors -from .._utils.url import ensure_leading_slash, sandbox_base_url, websocket_url_from_http from ..models.config import DEFAULT_MEMORY_MIB, DEFAULT_TEMPLATE_NAME, DEFAULT_TIMEOUT_MIN, DEFAULT_VCPU from ..models.sandbox import CreateSandboxParams, Sandbox as SandboxData, SandboxRef, SandboxStatus, sandbox_id_of from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict, SandboxStatusResponseDict +from .._utils.errors import intercept_errors +from .._utils.url import ensure_leading_slash, sandbox_base_url, websocket_url_from_http +from ._transport import AsyncTransport AsyncSandboxT = TypeVar("AsyncSandboxT", SandboxData, SandboxStatus, "AsyncSandbox") +if TYPE_CHECKING: + from .client import AsyncLeap0Client + _OTEL_ENV_KEYS = ( "OTEL_EXPORTER_OTLP_ENDPOINT", @@ -49,7 +52,6 @@ class AsyncSandbox: Attributes: filesystem: Bound filesystem client. - fs: Alias for ``filesystem``. git: Bound git client. process: Bound process client. pty: Bound PTY client. @@ -58,11 +60,10 @@ class AsyncSandbox: code_interpreter: Bound code interpreter client. desktop: Bound desktop client. """ - def __init__(self, client: object, data: SandboxData | SandboxStatus): - self._client = client + def __init__(self, client: "AsyncLeap0Client", data: SandboxData | SandboxStatus): + self._client: "AsyncLeap0Client" = client self._data: SandboxData | SandboxStatus = data self.filesystem = _AsyncSandboxServiceProxy(client.filesystem, self) - self.fs = self.filesystem self.git = _AsyncSandboxServiceProxy(client.git, self) self.process = _AsyncSandboxServiceProxy(client.process, self) self.pty = _AsyncSandboxServiceProxy(client.pty, self) diff --git a/leap0/_async/snapshots.py b/leap0/_async/snapshots.py index 12969b8..1aee14c 100644 --- a/leap0/_async/snapshots.py +++ b/leap0/_async/snapshots.py @@ -2,13 +2,13 @@ from typing import Generic, TypeVar, cast -from ._transport import AsyncTransport from .._internal.types import SandboxFactory -from .._utils.errors import intercept_errors from ..models.sandbox import Sandbox, SandboxRef, sandbox_id_of -from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict from ..models.snapshot import CreateSnapshotParams, ResumeSnapshotParams, Snapshot, SnapshotRef, snapshot_id_of from .._schemas.snapshot import SnapshotCreateResponseDict +from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict +from .._utils.errors import intercept_errors +from ._transport import AsyncTransport AsyncSnapshotSandboxT = TypeVar("AsyncSnapshotSandboxT") diff --git a/leap0/_async/ssh.py b/leap0/_async/ssh.py index 28f9773..eed8f5e 100644 --- a/leap0/_async/ssh.py +++ b/leap0/_async/ssh.py @@ -2,10 +2,10 @@ from typing import cast -from ._transport import AsyncTransport -from .._utils.errors import intercept_errors from ..models.sandbox import SandboxRef, sandbox_id_of from ..models.ssh import SshAccess, SshValidation +from .._utils.errors import intercept_errors +from ._transport import AsyncTransport class AsyncSshClient: diff --git a/leap0/_async/templates.py b/leap0/_async/templates.py index bd38b5f..e1e7a30 100644 --- a/leap0/_async/templates.py +++ b/leap0/_async/templates.py @@ -2,8 +2,6 @@ from typing import cast -from ._transport import AsyncTransport -from .._utils.errors import intercept_errors from ..models.template import ( CreateTemplateParams, RegistryCredentialsDict, @@ -11,6 +9,8 @@ Template, ) from .._schemas.template import UploadTemplateResponseDict +from .._utils.errors import intercept_errors +from ._transport import AsyncTransport class AsyncTemplatesClient: diff --git a/leap0/_sync/_transport.py b/leap0/_sync/_transport.py index 9f5a155..5f23ec5 100644 --- a/leap0/_sync/_transport.py +++ b/leap0/_sync/_transport.py @@ -7,10 +7,10 @@ import httpx from .._internal.types import BinaryFiles, JsonObject -from .._utils.otel import with_instrumentation from .._internal.version import SDK_VERSION from ..models.config import DEFAULT_CLIENT_TIMEOUT from ..models.errors import raise_api_error +from .._utils.otel import with_instrumentation diff --git a/leap0/_sync/client.py b/leap0/_sync/client.py index e20dff7..c8bbb4b 100644 --- a/leap0/_sync/client.py +++ b/leap0/_sync/client.py @@ -1,19 +1,18 @@ from __future__ import annotations + from types import TracebackType from typing import Self from opentelemetry import metrics, trace +from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.semconv.attributes import service_attributes -from ._transport import Transport -from .._utils.otel import with_instrumentation -from .code_interpreter import CodeInterpreterClient -from .desktop import DesktopClient from ..models.config import ( DEFAULT_BASE_URL, DEFAULT_CLIENT_TIMEOUT, @@ -26,6 +25,10 @@ DEFAULT_VCPU, Leap0Config, ) +from .._utils.otel import with_instrumentation +from ._transport import Transport +from .code_interpreter import CodeInterpreterClient +from .desktop import DesktopClient from .filesystem import FilesystemClient from .git import GitClient from .lsp import LspClient @@ -148,7 +151,8 @@ def _init_otel(self) -> None: current_meter_provider = metrics.get_meter_provider() if not isinstance(current_meter_provider, MeterProvider): - self._meter_provider = MeterProvider(resource=resource) + metric_reader = PeriodicExportingMetricReader(OTLPMetricExporter()) + self._meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader]) metrics.set_meter_provider(self._meter_provider) self._owns_meter_provider = True else: diff --git a/leap0/_sync/code_interpreter.py b/leap0/_sync/code_interpreter.py index c804aaf..7eabe85 100644 --- a/leap0/_sync/code_interpreter.py +++ b/leap0/_sync/code_interpreter.py @@ -6,14 +6,14 @@ import httpx from .._internal.types import JsonObject -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 ..models.code_interpreter import ( CodeContext, CodeContextDict, CodeExecutionResult, CodeExecutionResultDict, StreamEvent, StreamEventDict, ) from ..models.sandbox import SandboxRef, sandbox_id_of +from .._utils.errors import intercept_errors +from .._utils.stream import iter_sse_events +from .._utils.url import sandbox_base_url +from ._transport import Transport class CodeInterpreterClient: diff --git a/leap0/_sync/desktop.py b/leap0/_sync/desktop.py index 91b1b6d..55d9792 100644 --- a/leap0/_sync/desktop.py +++ b/leap0/_sync/desktop.py @@ -2,17 +2,12 @@ import time from collections.abc import Iterator -from typing import Any, cast +from typing import cast import httpx from tenacity import retry, retry_if_exception, stop_after_delay, wait_exponential from .._internal.types import JsonObject -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 ..models.errors import Leap0Error, Leap0TimeoutError from ..models.desktop import ( DesktopDisplayInfo, DesktopDisplayInfoDict, @@ -38,6 +33,11 @@ DesktopWindowsDict, ) from ..models.sandbox import SandboxRef, sandbox_id_of +from ..models.errors import Leap0Error, Leap0TimeoutError +from .._utils.errors import intercept_errors +from .._utils.stream import iter_sse_events +from .._utils.url import sandbox_base_url +from ._transport import Transport class DesktopClient: @@ -583,11 +583,22 @@ def status_stream(self, sandbox: SandboxRef, *, deadline: float | None = None, h object: Items yielded by this operation. """ url = f"{sandbox_base_url(sandbox_id_of(sandbox), self._sandbox_domain)}/api/status/stream" - response = self._transport.stream("GET", url, timeout=http_timeout) + stream_timeout = http_timeout + if deadline is not None: + remaining_time = deadline - time.monotonic() + if remaining_time <= 0: + raise Leap0TimeoutError("Desktop status stream timed out") + stream_timeout = remaining_time if stream_timeout is None else min(stream_timeout, remaining_time) + response = self._transport.stream("GET", url, timeout=stream_timeout) try: - for event in iter_sse_events(response.iter_lines()): + events = iter_sse_events(response.iter_lines()) + while True: if deadline is not None and time.monotonic() >= deadline: raise Leap0TimeoutError("Desktop status stream timed out") + try: + event = next(events) + except StopIteration: + break # Non-dict events are heartbeat/info frames; skip them. if not isinstance(event, dict): continue @@ -649,7 +660,3 @@ def _poll() -> None: 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/_sync/filesystem.py b/leap0/_sync/filesystem.py index 31938ce..e64fa60 100644 --- a/leap0/_sync/filesystem.py +++ b/leap0/_sync/filesystem.py @@ -3,11 +3,11 @@ from typing import Any, cast from .._internal.types import JsonObject -from ._transport import Transport -from .._utils.errors import intercept_errors from ..models.filesystem import EditFileResult, EditResult, FileEdit, FileInfo, LsResult, SearchMatch, TreeResult -from .._schemas.filesystem import EditFileResponseDict, EditFilesResponseDict, ExistsResponseDict, FileInfoDict, GlobResponseDict, GrepResponseDict, LsResponseDict, TreeResponseDict 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 ._transport import Transport class FilesystemClient: @@ -203,30 +203,16 @@ def read_bytes( limit: Maximum bytes to read. head: Return only the first N lines (mutually exclusive with *tail*). tail: Return only the last N lines (mutually exclusive with *head*). - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - Args: - sandbox: Sandbox ID or object. - path: Path to the file. - offset: Byte offset to start from. - limit: Maximum bytes to read. - head: Return only the first N lines. - tail: Return only the last N lines. - encoding: Text encoding used to decode the response body. - - http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: - str: Decoded file contents. + bytes: File contents as raw bytes. Example: ```python - content = sandbox.filesystem.read_file(path="/workspace/README.md") + content = sandbox.filesystem.read_bytes(path="/workspace/logo.png") print(content) ``` - - Returns: - bytes: File contents as raw bytes. """ payload: JsonObject = {"path": path} if offset is not None: @@ -450,7 +436,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: + def move(self, sandbox: SandboxRef, *, src_path: str, dst_path: str, overwrite: bool = False, http_timeout: float | None = None) -> None: """Move or rename a file or directory. Args: @@ -464,6 +450,7 @@ def move(self, sandbox: SandboxRef, *, src_path: str, dst_path: str, overwrite: f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/move", json={"src_path": src_path, "dst_path": dst_path, "overwrite": overwrite}, expected_status=204, + timeout=http_timeout, ) @intercept_errors("Failed to copy: ") diff --git a/leap0/_sync/git.py b/leap0/_sync/git.py index 999c669..386768d 100644 --- a/leap0/_sync/git.py +++ b/leap0/_sync/git.py @@ -3,11 +3,11 @@ from typing import cast from .._internal.types import JsonObject -from ._transport import Transport -from .._utils.errors import intercept_errors from ..models.git import GitCommitResult, GitResult -from .._schemas.git import GitCommitResponseDict, GitResultDict from ..models.sandbox import SandboxRef, sandbox_id_of +from .._schemas.git import GitCommitResponseDict, GitResultDict +from .._utils.errors import intercept_errors +from ._transport import Transport class GitClient: @@ -165,7 +165,7 @@ def diff(self, sandbox: SandboxRef, *, path: str, target: str, context_lines: in return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/diff", payload, http_timeout=http_timeout) @intercept_errors("Failed to reset: ") - def reset(self, sandbox: SandboxRef, *, path: str) -> GitResult: + def reset(self, sandbox: SandboxRef, *, path: str, http_timeout: float | None = None) -> GitResult: """Unstage all currently staged changes. Args: @@ -175,7 +175,7 @@ def reset(self, sandbox: SandboxRef, *, path: str) -> GitResult: Returns: object: Result returned by this operation. """ - return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/reset", {"path": path}) + return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/reset", {"path": path}, http_timeout=http_timeout) @intercept_errors("Failed to get git log: ") def log( @@ -211,7 +211,7 @@ def log( return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/log", payload, http_timeout=http_timeout) @intercept_errors("Failed to show revision: ") - def show(self, sandbox: SandboxRef, *, path: str, revision: str = "HEAD") -> GitResult: + def show(self, sandbox: SandboxRef, *, path: str, revision: str = "HEAD", http_timeout: float | None = None) -> GitResult: """Show the full output for a commit, branch, or tag revision. Args: @@ -222,7 +222,7 @@ def show(self, sandbox: SandboxRef, *, path: str, revision: str = "HEAD") -> Git Returns: object: Result returned by this operation. """ - return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/show", {"path": path, "revision": revision}) + return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/show", {"path": path, "revision": revision}, http_timeout=http_timeout) @intercept_errors("Failed to create branch: ") def create_branch( @@ -272,7 +272,7 @@ def checkout_branch(self, sandbox: SandboxRef, *, path: str, branch: str, create return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/checkout-branch", payload, http_timeout=http_timeout) @intercept_errors("Failed to delete branch: ") - def delete_branch(self, sandbox: SandboxRef, *, path: str, name: str, force: bool = False) -> GitResult: + def delete_branch(self, sandbox: SandboxRef, *, path: str, name: str, force: bool = False, http_timeout: float | None = None) -> GitResult: """Delete a branch. Set *force* to delete even if unmerged. Args: @@ -284,7 +284,7 @@ def delete_branch(self, sandbox: SandboxRef, *, path: str, name: str, force: boo Returns: object: Result returned by this operation. """ - return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/delete-branch", {"path": path, "name": name, "force": force}) + return self._git_result(f"/v1/sandbox/{sandbox_id_of(sandbox)}/git/delete-branch", {"path": path, "name": name, "force": force}, http_timeout=http_timeout) @intercept_errors("Failed to stage files: ") def add(self, sandbox: SandboxRef, *, path: str, files: list[str], http_timeout: float | None = None) -> GitResult: diff --git a/leap0/_sync/lsp.py b/leap0/_sync/lsp.py index e2204f5..09829d3 100644 --- a/leap0/_sync/lsp.py +++ b/leap0/_sync/lsp.py @@ -3,12 +3,12 @@ from typing import cast from .._internal.types import JsonObject -from ._transport import Transport -from .._utils.errors import intercept_errors -from .._utils.url import file_uri as _file_uri from ..models.lsp import LspJsonRpcResponse, LspResponse -from .._schemas.lsp import LspJsonRpcResponseDict, LspSuccessResponseDict from ..models.sandbox import SandboxRef, sandbox_id_of +from .._schemas.lsp import LspJsonRpcResponseDict, LspSuccessResponseDict +from .._utils.errors import intercept_errors +from .._utils.url import file_uri as _file_uri +from ._transport import Transport class LspClient: @@ -40,15 +40,6 @@ def start(self, sandbox: SandboxRef, *, language_id: str, path_to_project: str) language_id: Language identifier (``"python"``, ``"typescript"``, or ``"javascript"``). path_to_project: Project directory path inside the sandbox. - Args: - sandbox: Sandbox ID or object. - language_id: Language identifier. - path_to_project: Project directory path. - uri: Document URI (e.g. ``"file:///home/user/project/main.py"``). - text: Full document text. - version: Document version number. - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - Returns: LspResponse: Server startup result. """ @@ -123,10 +114,15 @@ def did_open_path( version: int = 1, http_timeout: float | None = None, ) -> None: - """ - Like :meth:`did_open` but accepts a file path instead of a URI. + """Like :meth:`did_open` but accepts a file path instead of a URI. - Args: + Args: + sandbox: Sandbox ID or object. + language_id: Language identifier for the LSP operation. + path_to_project: Project path inside the sandbox. + path: File path inside the sandbox. + text: Optional full document text to send with the open notification. + version: Document version number. http_timeout: Optional HTTP request timeout in seconds for this SDK call. """ self.did_open(sandbox, language_id=language_id, path_to_project=path_to_project, uri=_file_uri(path), text=text, version=version, http_timeout=http_timeout) diff --git a/leap0/_sync/process.py b/leap0/_sync/process.py index 6d5abf0..3140df9 100644 --- a/leap0/_sync/process.py +++ b/leap0/_sync/process.py @@ -1,13 +1,13 @@ from __future__ import annotations -from typing import Any, cast +from typing import cast from .._internal.types import JsonObject -from ._transport import Transport -from .._utils.errors import intercept_errors from ..models.process import ProcessResult -from .._schemas.process import ProcessResultDict from ..models.sandbox import SandboxRef, sandbox_id_of +from .._schemas.process import ProcessResultDict +from .._utils.errors import intercept_errors +from ._transport import Transport class ProcessClient: @@ -24,7 +24,7 @@ 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: + def execute(self, sandbox: SandboxRef, *, command: str, cwd: str | None = None, timeout: int | None = None, http_timeout: int | None = None) -> ProcessResult: """Run a shell command and wait for the result. The command runs inside ``/bin/sh -c``. @@ -34,6 +34,7 @@ def execute(self, sandbox: SandboxRef, *, command: str, cwd: str | None = None, command: Shell command to execute. cwd: Working directory. timeout: Timeout in seconds. If omitted, the server-side default is used. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: ProcessResult: Command result including exit code, stdout, and stderr. @@ -52,5 +53,5 @@ def execute(self, sandbox: SandboxRef, *, command: str, cwd: str | None = None, payload["cwd"] = cwd if timeout is not None: payload["timeout"] = timeout - data = cast(ProcessResultDict, self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/process/execute", json=payload)) + data = cast(ProcessResultDict, self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/process/execute", json=payload, timeout=http_timeout)) return ProcessResult.from_dict(data) diff --git a/leap0/_sync/pty.py b/leap0/_sync/pty.py index b17dde3..144dce2 100644 --- a/leap0/_sync/pty.py +++ b/leap0/_sync/pty.py @@ -6,12 +6,12 @@ from websockets.sync.client import connect from .._internal.types import JsonObject -from ._transport import Transport -from .._utils.errors import intercept_errors -from .._utils.url import websocket_url_from_http from ..models.pty import CreatePtySessionParams, PtyConnection, PtySession -from .._schemas.pty import PtyListResponseDict, PtySessionInfoDict from ..models.sandbox import SandboxRef, sandbox_id_of +from .._schemas.pty import PtyListResponseDict, PtySessionInfoDict +from .._utils.errors import intercept_errors +from .._utils.url import websocket_url_from_http +from ._transport import Transport class PtyClient: diff --git a/leap0/_sync/sandbox.py b/leap0/_sync/sandbox.py index d9adc4d..8453bd8 100644 --- a/leap0/_sync/sandbox.py +++ b/leap0/_sync/sandbox.py @@ -4,13 +4,13 @@ from functools import wraps from typing import Generic, Protocol, TypeVar, cast -from ._transport import Transport -from .._utils.errors import intercept_errors -from .._utils.url import ensure_leading_slash, sandbox_base_url, websocket_url_from_http from .._internal.types import SandboxFactory from ..models.config import DEFAULT_MEMORY_MIB, DEFAULT_TEMPLATE_NAME, DEFAULT_TIMEOUT_MIN, DEFAULT_VCPU from ..models.sandbox import CreateSandboxParams, Sandbox as SandboxData, SandboxRef, SandboxStatus, sandbox_id_of from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict, SandboxStatusResponseDict +from .._utils.errors import intercept_errors +from .._utils.url import ensure_leading_slash, sandbox_base_url, websocket_url_from_http +from ._transport import Transport _OTEL_ENV_KEYS = ( @@ -55,7 +55,6 @@ class Sandbox: Attributes: filesystem: Bound filesystem client. - fs: Alias for ``filesystem``. git: Bound git client. process: Bound process client. pty: Bound PTY client. @@ -69,7 +68,6 @@ def __init__(self, client: object, data: SandboxData | SandboxStatus): self._client = client self._data: SandboxData | SandboxStatus = data self.filesystem = _SandboxServiceProxy(client.filesystem, self) - self.fs = self.filesystem self.git = _SandboxServiceProxy(client.git, self) self.process = _SandboxServiceProxy(client.process, self) self.pty = _SandboxServiceProxy(client.pty, self) diff --git a/leap0/_sync/snapshots.py b/leap0/_sync/snapshots.py index c96ef44..fc5adb3 100644 --- a/leap0/_sync/snapshots.py +++ b/leap0/_sync/snapshots.py @@ -2,13 +2,13 @@ from typing import Generic, TypeVar, cast -from ._transport import Transport from .._internal.types import SandboxFactory -from .._utils.errors import intercept_errors from ..models.sandbox import Sandbox, SandboxRef, sandbox_id_of -from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict from ..models.snapshot import CreateSnapshotParams, ResumeSnapshotParams, Snapshot, SnapshotRef, snapshot_id_of from .._schemas.snapshot import SnapshotCreateResponseDict +from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict +from .._utils.errors import intercept_errors +from ._transport import Transport SnapshotSandboxT = TypeVar("SnapshotSandboxT") diff --git a/leap0/_sync/ssh.py b/leap0/_sync/ssh.py index 652e928..53c007c 100644 --- a/leap0/_sync/ssh.py +++ b/leap0/_sync/ssh.py @@ -2,10 +2,10 @@ from typing import cast -from ._transport import Transport -from .._utils.errors import intercept_errors from ..models.sandbox import SandboxRef, sandbox_id_of from ..models.ssh import SshAccess, SshValidation +from .._utils.errors import intercept_errors +from ._transport import Transport class SshClient: diff --git a/leap0/_sync/templates.py b/leap0/_sync/templates.py index 658eb8b..0a4e655 100644 --- a/leap0/_sync/templates.py +++ b/leap0/_sync/templates.py @@ -2,8 +2,6 @@ from typing import cast -from ._transport import Transport -from .._utils.errors import intercept_errors from ..models.template import ( CreateTemplateParams, RegistryCredentialsDict, @@ -11,6 +9,8 @@ Template, ) from .._schemas.template import UploadTemplateResponseDict +from .._utils.errors import intercept_errors +from ._transport import Transport class TemplatesClient: diff --git a/leap0/models/pty.py b/leap0/models/pty.py index d3b1c81..64dc309 100644 --- a/leap0/models/pty.py +++ b/leap0/models/pty.py @@ -40,6 +40,27 @@ def to_payload(self) -> dict[str, object]: payload["id"] = session_id return payload + +class PtyResizeParams(BaseModel): + """Validated PTY resize parameters.""" + + model_config = ConfigDict(extra="forbid") + + cols: int + rows: int + + @model_validator(mode="after") + def _validate_values(self) -> PtyResizeParams: + if self.cols < 1: + raise ValueError("cols must be at least 1") + if self.rows < 1: + raise ValueError("rows must be at least 1") + return self + + def to_payload(self) -> dict[str, object]: + """Convert this object to an API request payload.""" + return self.model_dump() + @dataclass(slots=True) class PtySession: """PTY session metadata.""" diff --git a/leap0/models/sandbox.py b/leap0/models/sandbox.py index 80cc14e..f4c8bda 100644 --- a/leap0/models/sandbox.py +++ b/leap0/models/sandbox.py @@ -5,9 +5,10 @@ from typing import Protocol from pydantic import BaseModel, ConfigDict, model_validator + from .._internal.types import JsonObject -from .config import DEFAULT_MEMORY_MIB, DEFAULT_TEMPLATE_NAME, DEFAULT_TIMEOUT_MIN, DEFAULT_VCPU from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict, SandboxStatusResponseDict, TransformRuleDict +from .config import DEFAULT_MEMORY_MIB, DEFAULT_TEMPLATE_NAME, DEFAULT_TIMEOUT_MIN, DEFAULT_VCPU class SandboxState(str, Enum): """Lifecycle states for a sandbox.""" diff --git a/leap0/models/snapshot.py b/leap0/models/snapshot.py index fc9b995..5bf7483 100644 --- a/leap0/models/snapshot.py +++ b/leap0/models/snapshot.py @@ -4,8 +4,9 @@ from typing import Protocol from pydantic import BaseModel, ConfigDict, model_validator -from .sandbox import NetworkPolicyDict, NetworkPolicyMode, SandboxState, _parse_sandbox_state + from .._schemas.snapshot import SnapshotCreateResponseDict +from .sandbox import NetworkPolicyDict, NetworkPolicyMode, SandboxState, _parse_sandbox_state class CreateSnapshotParams(BaseModel): """Validated snapshot creation parameters.""" From 2885eb7b8231a777877edbf2dafac19125967e9b Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Wed, 1 Apr 2026 15:01:02 -0400 Subject: [PATCH 4/6] fixed comments --- leap0/_async/client.py | 113 +++++++++++++++++++++----- leap0/_async/code_interpreter.py | 13 ++- leap0/_async/desktop.py | 7 +- leap0/_async/filesystem.py | 21 +++-- leap0/_async/sandbox.py | 7 ++ leap0/_sync/_transport.py | 14 +++- leap0/_sync/code_interpreter.py | 13 ++- leap0/_sync/desktop.py | 6 +- leap0/_sync/pty.py | 4 +- leap0/_utils/otel.py | 9 ++ tests/_async/test_code_interpreter.py | 56 +++++++++++++ tests/_async/test_filesystem.py | 34 +++++++- tests/_async/test_sandbox_proxy.py | 33 ++++++++ tests/_sync/test_code_interpreter.py | 45 ++++++++++ tests/_sync/test_desktop.py | 18 ++++ tests/_sync/test_pty.py | 11 +++ tests/_sync/test_transport.py | 19 +++++ 17 files changed, 381 insertions(+), 42 deletions(-) create mode 100644 tests/_async/test_code_interpreter.py create mode 100644 tests/_async/test_sandbox_proxy.py create mode 100644 tests/_sync/test_code_interpreter.py create mode 100644 tests/_sync/test_desktop.py diff --git a/leap0/_async/client.py b/leap0/_async/client.py index 5409eb1..6070fc5 100644 --- a/leap0/_async/client.py +++ b/leap0/_async/client.py @@ -1,17 +1,25 @@ from __future__ import annotations import asyncio +import threading from types import TracebackType from opentelemetry import metrics, trace +from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.semconv.attributes import service_attributes +from opentelemetry.trace import ProxyTracerProvider + +from opentelemetry.metrics import _internal as metrics_internal +from opentelemetry.util._once import Once from .._internal.version import SDK_VERSION +from .._utils.otel import clear_cached_otel from ..models.config import ( DEFAULT_BASE_URL, DEFAULT_CLIENT_TIMEOUT, @@ -39,6 +47,27 @@ from .templates import AsyncTemplatesClient +_otel_lock = threading.Lock() +_shared_tracer_provider: TracerProvider | None = None +_shared_tracer_refcount = 0 +_shared_meter_provider: MeterProvider | None = None +_shared_meter_refcount = 0 + + +def _reset_tracer_provider_if_current(provider: TracerProvider) -> None: + if trace.get_tracer_provider() is not provider: + return + trace._TRACER_PROVIDER = ProxyTracerProvider() # type: ignore[attr-defined] + trace._TRACER_PROVIDER_SET_ONCE = Once() # type: ignore[attr-defined] + + +def _reset_meter_provider_if_current(provider: MeterProvider) -> None: + if metrics.get_meter_provider() is not provider: + return + metrics_internal._METER_PROVIDER = metrics_internal._PROXY_METER_PROVIDER # type: ignore[attr-defined] + metrics_internal._METER_PROVIDER_SET_ONCE = Once() # type: ignore[attr-defined] + + class AsyncLeap0Client: """Top-level asynchronous client for the Leap0 API. @@ -113,8 +142,9 @@ def __init__( auth_header=config.auth_header, bearer=config.bearer, ) - self._owns_tracer_provider = False - self._owns_meter_provider = False + self._uses_shared_tracer_provider = False + self._uses_shared_meter_provider = False + self._closed = False self.sandboxes: AsyncSandboxesClient[AsyncSandbox] = AsyncSandboxesClient( self._transport, sandbox_domain=config.sandbox_domain, @@ -138,30 +168,46 @@ def __init__( self._init_otel() def _init_otel(self) -> None: - self._owns_tracer_provider = False - self._owns_meter_provider = False resource = Resource.create( { service_attributes.SERVICE_NAME: "leap0-python-sdk", service_attributes.SERVICE_VERSION: SDK_VERSION, } ) + self._uses_shared_tracer_provider = False + self._uses_shared_meter_provider = False + + global _shared_tracer_provider, _shared_tracer_refcount current_tracer_provider = trace.get_tracer_provider() - if not isinstance(current_tracer_provider, TracerProvider): - self._tracer_provider = TracerProvider(resource=resource) - self._tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) - trace.set_tracer_provider(self._tracer_provider) - self._owns_tracer_provider = True - else: - self._tracer_provider = current_tracer_provider + with _otel_lock: + if _shared_tracer_provider is None and isinstance(current_tracer_provider, TracerProvider): + self._tracer_provider = current_tracer_provider + else: + if _shared_tracer_provider is None: + tracer_provider = TracerProvider(resource=resource) + tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + _shared_tracer_provider = tracer_provider + trace.set_tracer_provider(tracer_provider) + clear_cached_otel() + _shared_tracer_refcount += 1 + self._tracer_provider = _shared_tracer_provider + self._uses_shared_tracer_provider = True + global _shared_meter_provider, _shared_meter_refcount current_meter_provider = metrics.get_meter_provider() - if not isinstance(current_meter_provider, MeterProvider): - self._meter_provider = MeterProvider(resource=resource) - metrics.set_meter_provider(self._meter_provider) - self._owns_meter_provider = True - else: - self._meter_provider = current_meter_provider + with _otel_lock: + if _shared_meter_provider is None and isinstance(current_meter_provider, MeterProvider): + self._meter_provider = current_meter_provider + else: + if _shared_meter_provider is None: + metric_reader = PeriodicExportingMetricReader(OTLPMetricExporter()) + meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader]) + _shared_meter_provider = meter_provider + metrics.set_meter_provider(meter_provider) + clear_cached_otel() + _shared_meter_refcount += 1 + self._meter_provider = _shared_meter_provider + self._uses_shared_meter_provider = True @with_instrumentation("async_client.get_sandbox") async def get_sandbox(self, sandbox_id: str) -> AsyncSandbox: @@ -190,11 +236,36 @@ async def create_sandbox(self, **kwargs: object) -> AsyncSandbox: @with_instrumentation("async_client.close") async def close(self) -> None: """Close the client and release resources.""" + if self._closed: + return + self._closed = True await self._transport.close() - if self._owns_tracer_provider and self._tracer_provider is not None: - await asyncio.to_thread(self._tracer_provider.shutdown) - if self._owns_meter_provider and self._meter_provider is not None: - await asyncio.to_thread(self._meter_provider.shutdown) + + tracer_to_shutdown: TracerProvider | None = None + meter_to_shutdown: MeterProvider | None = None + + global _shared_tracer_provider, _shared_tracer_refcount + global _shared_meter_provider, _shared_meter_refcount + with _otel_lock: + if self._uses_shared_tracer_provider and self._tracer_provider is _shared_tracer_provider: + _shared_tracer_refcount -= 1 + if _shared_tracer_refcount == 0 and _shared_tracer_provider is not None: + tracer_to_shutdown = _shared_tracer_provider + _reset_tracer_provider_if_current(_shared_tracer_provider) + _shared_tracer_provider = None + clear_cached_otel() + if self._uses_shared_meter_provider and self._meter_provider is _shared_meter_provider: + _shared_meter_refcount -= 1 + if _shared_meter_refcount == 0 and _shared_meter_provider is not None: + meter_to_shutdown = _shared_meter_provider + _reset_meter_provider_if_current(_shared_meter_provider) + _shared_meter_provider = None + clear_cached_otel() + + if tracer_to_shutdown is not None: + await asyncio.to_thread(tracer_to_shutdown.shutdown) + if meter_to_shutdown is not None: + await asyncio.to_thread(meter_to_shutdown.shutdown) async def __aenter__(self) -> AsyncLeap0Client: return self diff --git a/leap0/_async/code_interpreter.py b/leap0/_async/code_interpreter.py index ae9b26d..458275e 100644 --- a/leap0/_async/code_interpreter.py +++ b/leap0/_async/code_interpreter.py @@ -14,6 +14,7 @@ StreamEvent, StreamEventDict, ) +from ..models.errors import Leap0Error from ..models.sandbox import SandboxRef, sandbox_id_of from .._utils.errors import intercept_errors from .._utils.stream import aiter_sse_events @@ -31,6 +32,14 @@ def __init__(self, transport: AsyncTransport, *, sandbox_domain: str | None = No self._transport = transport self._sandbox_domain = sandbox_domain.strip("/") if sandbox_domain else None + def _stream_event_from_sse(self, event: object) -> StreamEvent | None: + if not isinstance(event, dict): + return None + if event.get("envelope") == "error": + message = str(event.get("error") or event.get("message") or "Code execution stream error") + raise Leap0Error(message, body=str(event)) + return StreamEvent.from_dict(cast(StreamEventDict, event)) + async def _request( self, method: str, @@ -214,7 +223,9 @@ async def execute_stream( ) try: async for event in aiter_sse_events(response.aiter_lines()): - yield StreamEvent.from_dict(cast(StreamEventDict, event)) + stream_event = self._stream_event_from_sse(event) + if stream_event is not None: + yield stream_event finally: await response.aclose() diff --git a/leap0/_async/desktop.py b/leap0/_async/desktop.py index 17e7790..3d019d0 100644 --- a/leap0/_async/desktop.py +++ b/leap0/_async/desktop.py @@ -605,7 +605,12 @@ async def wait_until_ready(self, sandbox: SandboxRef, *, timeout: float = 60.0, raise Leap0TimeoutError(f"Desktop did not become ready within {timeout:.0f}s: {exc}") from exc except Leap0Error as exc: last_error = exc - await asyncio.sleep(delay) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise Leap0TimeoutError( + f"Desktop did not become ready within {timeout:.0f}s: {exc}" + ) from exc + await asyncio.sleep(min(delay, remaining)) delay = min(delay * 2, 5.0) if last_error is not None: raise Leap0TimeoutError(f"Desktop did not become ready within {timeout:.0f}s: {last_error}") from last_error diff --git a/leap0/_async/filesystem.py b/leap0/_async/filesystem.py index d222ebf..267c904 100644 --- a/leap0/_async/filesystem.py +++ b/leap0/_async/filesystem.py @@ -25,7 +25,7 @@ def __init__(self, transport: AsyncTransport): self._transport = transport @intercept_errors("Failed to list directory: ") - async def ls(self, sandbox: SandboxRef, *, path: str, recursive: bool = False, exclude: list[str] | None = None) -> LsResult: + async def ls(self, sandbox: SandboxRef, *, path: str, recursive: bool = False, exclude: list[str] | None = None, http_timeout: float | None = None) -> LsResult: """List directory entries. Args: @@ -33,6 +33,7 @@ async def ls(self, sandbox: SandboxRef, *, path: str, recursive: bool = False, e path: Directory path to list. recursive: List recursively. exclude: Glob patterns to exclude. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: object: Result returned by this operation. @@ -40,21 +41,22 @@ async def ls(self, sandbox: SandboxRef, *, path: str, recursive: bool = False, e payload: JsonObject = {"path": path, "recursive": recursive} if exclude is not None: payload["exclude"] = exclude - data = cast(LsResponseDict, await self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/ls", json=payload)) + data = cast(LsResponseDict, await self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/ls", json=payload, timeout=http_timeout)) return LsResult.from_dict(data) @intercept_errors("Failed to stat file: ") - async def stat(self, sandbox: SandboxRef, *, path: str) -> FileInfo: + async def stat(self, sandbox: SandboxRef, *, path: str, http_timeout: float | None = None) -> FileInfo: """Get metadata for a single path. Args: sandbox: Sandbox ID or object. path: Path used by this operation. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: object: Result returned by this operation. """ - data = cast(FileInfoDict, await self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/stat", json={"path": path})) + data = cast(FileInfoDict, await self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/filesystem/stat", json={"path": path}, timeout=http_timeout)) return FileInfo.from_dict(data) @intercept_errors("Failed to create directory: ") @@ -92,7 +94,7 @@ async def write_bytes(self, sandbox: SandboxRef, *, path: str, content: bytes, p http_timeout: Optional HTTP request timeout in seconds for this SDK call. Example: ```python - client.filesystem.write_bytes( + await client.filesystem.write_bytes( sandbox, path="/workspace/logo.png", content=image_bytes, @@ -126,7 +128,7 @@ async def write_file(self, sandbox: SandboxRef, *, path: str, content: str, enco http_timeout: Optional HTTP request timeout in seconds for this SDK call. Example: ```python - client.filesystem.write_file( + await client.filesystem.write_file( sandbox, path="/workspace/app.py", content="print('hello')\n", @@ -152,7 +154,7 @@ async def write_files_bytes(self, sandbox: SandboxRef, *, files: dict[str, bytes http_timeout: Optional HTTP request timeout in seconds for this SDK call. Example: ```python - client.filesystem.write_files_bytes( + await client.filesystem.write_files_bytes( sandbox, files={"/workspace/a.bin": b"a", "/workspace/b.bin": b"b"}, ) @@ -214,6 +216,8 @@ 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 @@ -523,10 +527,9 @@ def _parse_multipart_response(content_type: str, body: bytes) -> dict[str, bytes result: dict[str, bytes] = {} if not msg.is_multipart(): - body_preview = body[:200] if len(body) > 200 else body raise ValueError( f"Expected multipart response but got content_type={content_type!r} " - f"(body length={len(body)}, preview={body_preview!r})" + f"(body length={len(body)}, preview='')" ) for part in msg.get_payload(): # type: ignore[union-attr] name = part.get_param("name", header="content-disposition") diff --git a/leap0/_async/sandbox.py b/leap0/_async/sandbox.py index 9c0b154..909c1d9 100644 --- a/leap0/_async/sandbox.py +++ b/leap0/_async/sandbox.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect import os from functools import wraps from typing import TYPE_CHECKING, Generic, Protocol, TypeVar, cast @@ -33,6 +34,12 @@ def __getattr__(self, name: str) -> object: attr = getattr(self._service, name) if not callable(attr): return attr + if not inspect.iscoroutinefunction(attr): + @wraps(attr) + def sync_bound(*args: object, **kwargs: object) -> object: + return attr(self._sandbox, *args, **kwargs) + + return sync_bound bound_attr = cast(_AsyncBoundSandboxCallable, attr) diff --git a/leap0/_sync/_transport.py b/leap0/_sync/_transport.py index 5f23ec5..aa33d22 100644 --- a/leap0/_sync/_transport.py +++ b/leap0/_sync/_transport.py @@ -98,6 +98,14 @@ def headers(self, extra: dict[str, str] | None = None) -> dict[str, str]: def _expected(self, expected_status: int | tuple[int, ...]) -> tuple[int, ...]: return (expected_status,) if isinstance(expected_status, int) else expected_status + def _effective_timeout(self, timeout: float | None) -> float: + override = self._timeout_override.get() + if timeout is not None: + return timeout + if override is not None: + return override + return self.timeout + def _target_url(self, target: str) -> str: if target.startswith("https://") or target.startswith("http://"): return target @@ -137,7 +145,7 @@ def _request( content=content, files=files, headers=self.headers(headers), - timeout=timeout or self._timeout_override.get() or self.timeout, + timeout=self._effective_timeout(timeout), ) return self._check_response(response, method, target, expected_status) @@ -150,7 +158,7 @@ def _stream( json: JsonObject | None = None, timeout: float | None = None, ) -> httpx.Response: - effective = timeout if timeout is not None else (self._timeout_override.get() or self.timeout) + effective = self._effective_timeout(timeout) request = self._client.build_request( method, self._target_url(target), @@ -206,7 +214,7 @@ def request( content=content, files=files, headers=actual_headers, - timeout=timeout or self._timeout_override.get() or self.timeout, + timeout=self._effective_timeout(timeout), ) return self._check_response(response, method, path, expected_status) diff --git a/leap0/_sync/code_interpreter.py b/leap0/_sync/code_interpreter.py index 7eabe85..6667279 100644 --- a/leap0/_sync/code_interpreter.py +++ b/leap0/_sync/code_interpreter.py @@ -9,6 +9,7 @@ from ..models.code_interpreter import ( CodeContext, CodeContextDict, CodeExecutionResult, CodeExecutionResultDict, StreamEvent, StreamEventDict, ) +from ..models.errors import Leap0Error from ..models.sandbox import SandboxRef, sandbox_id_of from .._utils.errors import intercept_errors from .._utils.stream import iter_sse_events @@ -40,6 +41,14 @@ def __init__(self, transport: Transport, *, sandbox_domain: str | None = None): self._transport = transport self._sandbox_domain = sandbox_domain.strip("/") if sandbox_domain else None + def _stream_event_from_sse(self, event: object) -> StreamEvent | None: + if not isinstance(event, dict): + return None + if event.get("envelope") == "error": + message = str(event.get("error") or event.get("message") or "Code execution stream error") + raise Leap0Error(message, body=str(event)) + return StreamEvent.from_dict(cast(StreamEventDict, event)) + def _request( self, method: str, @@ -264,6 +273,8 @@ def execute_stream( ) try: for event in iter_sse_events(response.iter_lines()): - yield StreamEvent.from_dict(cast(StreamEventDict, event)) + stream_event = self._stream_event_from_sse(event) + if stream_event is not None: + yield stream_event finally: response.close() diff --git a/leap0/_sync/desktop.py b/leap0/_sync/desktop.py index 55d9792..1a7771f 100644 --- a/leap0/_sync/desktop.py +++ b/leap0/_sync/desktop.py @@ -599,9 +599,11 @@ def status_stream(self, sandbox: SandboxRef, *, deadline: float | None = None, h event = next(events) except StopIteration: break - # Non-dict events are heartbeat/info frames; skip them. if not isinstance(event, dict): - continue + raise ValueError( + "Malformed desktop status stream event " + f"for sandbox={sandbox_id_of(sandbox)!r}, source='status_stream': {event!r}" + ) # Explicit error envelope from the server. if "error" in event: raise Leap0Error( diff --git a/leap0/_sync/pty.py b/leap0/_sync/pty.py index 144dce2..2c65cfb 100644 --- a/leap0/_sync/pty.py +++ b/leap0/_sync/pty.py @@ -6,7 +6,7 @@ from websockets.sync.client import connect from .._internal.types import JsonObject -from ..models.pty import CreatePtySessionParams, PtyConnection, PtySession +from ..models.pty import CreatePtySessionParams, PtyConnection, PtyResizeParams, PtySession from ..models.sandbox import SandboxRef, sandbox_id_of from .._schemas.pty import PtyListResponseDict, PtySessionInfoDict from .._utils.errors import intercept_errors @@ -135,7 +135,7 @@ def resize(self, sandbox: SandboxRef, session_id: str, *, cols: int, rows: int) Returns: object: Result returned by this operation. """ - payload = CreatePtySessionParams(cols=cols, rows=rows).to_payload() + payload = PtyResizeParams(cols=cols, rows=rows).to_payload() encoded_session_id = quote(session_id, safe="") data = cast(PtySessionInfoDict, self._transport.request_json("POST", f"/v1/sandbox/{sandbox_id_of(sandbox)}/pty/{encoded_session_id}/resize", json=payload)) return PtySession.from_dict(data) diff --git a/leap0/_utils/otel.py b/leap0/_utils/otel.py index a7a96dc..7fbc572 100644 --- a/leap0/_utils/otel.py +++ b/leap0/_utils/otel.py @@ -84,6 +84,15 @@ def get_meter() -> _MeterProtocol: return cast(_MeterProtocol, _meter) +def clear_cached_otel() -> None: + """Clear cached tracer, meter, and histogram handles.""" + global _tracer, _meter + _tracer = None + _meter = None + with _histograms_lock: + _histograms.clear() + + def _metric_name(name: str) -> str: return name.replace(".", "_").lower() diff --git a/tests/_async/test_code_interpreter.py b/tests/_async/test_code_interpreter.py new file mode 100644 index 0000000..78a0c10 --- /dev/null +++ b/tests/_async/test_code_interpreter.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from leap0._async.code_interpreter import AsyncCodeInterpreterClient +from leap0.models.errors import Leap0Error + + +class TestAsyncCodeInterpreterClient: + def test_execute_stream_skips_non_dict_frames(self, async_mock_transport): + async def lines(): + yield "data: heartbeat" + yield "" + yield 'data: {"type": "stdout", "data": "ok"}' + yield "" + + async def run() -> None: + response = MagicMock() + response.aiter_lines = lambda: lines() + response.aclose = AsyncMock() + async_mock_transport.stream.return_value = response + + events = [ + event + async for event in AsyncCodeInterpreterClient( + async_mock_transport, + sandbox_domain="sandbox.example.com", + ).execute_stream("sbx-1", code="print('ok')") + ] + + assert [event.data for event in events] == ["ok"] + + asyncio.run(run()) + + def test_execute_stream_raises_on_error_envelope(self, async_mock_transport): + async def lines(): + yield 'data: {"envelope": "error", "message": "boom"}' + yield "" + + async def run() -> None: + response = MagicMock() + response.aiter_lines = lambda: lines() + response.aclose = AsyncMock() + async_mock_transport.stream.return_value = response + + with pytest.raises(Leap0Error, match="boom"): + async for _ in AsyncCodeInterpreterClient( + async_mock_transport, + sandbox_domain="sandbox.example.com", + ).execute_stream("sbx-1", code="print('ok')"): + pass + + asyncio.run(run()) diff --git a/tests/_async/test_filesystem.py b/tests/_async/test_filesystem.py index 9dda168..37a8ce7 100644 --- a/tests/_async/test_filesystem.py +++ b/tests/_async/test_filesystem.py @@ -3,7 +3,10 @@ import asyncio from unittest.mock import MagicMock -from leap0._async.filesystem import AsyncFilesystemClient +import pytest + +from leap0._async.filesystem import AsyncFilesystemClient, _parse_multipart_response +from leap0.models.errors import Leap0Error from leap0.models.filesystem import FileEdit @@ -11,8 +14,17 @@ class TestAsyncFilesystemClient: def test_ls(self, async_mock_transport): async def run() -> None: async_mock_transport.request_json.return_value = {"items": []} - await AsyncFilesystemClient(async_mock_transport).ls("sbx-1", path="/workspace") + await AsyncFilesystemClient(async_mock_transport).ls("sbx-1", path="/workspace", http_timeout=1.5) assert "/filesystem/ls" in async_mock_transport.request_json.call_args[0][1] + assert async_mock_transport.request_json.call_args.kwargs["timeout"] == 1.5 + + asyncio.run(run()) + + def test_stat_forwards_timeout(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = {"path": "/workspace", "name": "workspace", "type": "dir"} + await AsyncFilesystemClient(async_mock_transport).stat("sbx-1", path="/workspace", http_timeout=2.0) + assert async_mock_transport.request_json.call_args.kwargs["timeout"] == 2.0 asyncio.run(run()) @@ -31,3 +43,21 @@ async def run() -> None: assert async_mock_transport.request_json.call_args[1]["json"]["edits"] == [{"find": "old", "replace": "new"}] asyncio.run(run()) + + def test_read_bytes_rejects_head_and_tail(self, async_mock_transport): + async def run() -> None: + with pytest.raises(Leap0Error, match="mutually exclusive"): + await AsyncFilesystemClient(async_mock_transport).read_bytes( + "sbx-1", + path="/workspace/hello.txt", + head=1, + tail=1, + ) + + asyncio.run(run()) + + +class TestParseMultipartResponse: + def test_non_multipart_redacts_preview(self): + with pytest.raises(ValueError, match=""): + _parse_multipart_response("application/json", b'{"secret": "value"}') diff --git a/tests/_async/test_sandbox_proxy.py b/tests/_async/test_sandbox_proxy.py new file mode 100644 index 0000000..895af18 --- /dev/null +++ b/tests/_async/test_sandbox_proxy.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import asyncio + +from leap0._async.sandbox import _AsyncSandboxServiceProxy + + +class _Sandbox: + pass + + +class _Service: + async def async_method(self, sandbox, value: int) -> tuple[object, int]: + return sandbox, value + + def sync_method(self, sandbox, value: int) -> tuple[object, int]: + return sandbox, value + + +class TestAsyncSandboxServiceProxy: + def test_sync_methods_are_not_awaited(self): + sandbox = _Sandbox() + proxy = _AsyncSandboxServiceProxy(_Service(), sandbox) + + assert proxy.sync_method(3) == (sandbox, 3) + + def test_async_methods_are_bound(self): + async def run() -> None: + sandbox = _Sandbox() + proxy = _AsyncSandboxServiceProxy(_Service(), sandbox) + assert await proxy.async_method(3) == (sandbox, 3) + + asyncio.run(run()) diff --git a/tests/_sync/test_code_interpreter.py b/tests/_sync/test_code_interpreter.py new file mode 100644 index 0000000..8c4380c --- /dev/null +++ b/tests/_sync/test_code_interpreter.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from leap0._sync.code_interpreter import CodeInterpreterClient +from leap0.models.errors import Leap0Error + + +class TestCodeInterpreterClient: + def test_execute_stream_skips_non_dict_frames(self, mock_transport): + response = MagicMock() + response.iter_lines.return_value = iter([ + "data: heartbeat", + "", + 'data: {"type": "stdout", "data": "ok"}', + "", + ]) + mock_transport.stream.return_value = response + + events = list( + CodeInterpreterClient(mock_transport, sandbox_domain="sandbox.example.com").execute_stream( + "sbx-1", + code="print('ok')", + ) + ) + + assert [event.data for event in events] == ["ok"] + + def test_execute_stream_raises_on_error_envelope(self, mock_transport): + response = MagicMock() + response.iter_lines.return_value = iter([ + 'data: {"envelope": "error", "message": "boom"}', + "", + ]) + mock_transport.stream.return_value = response + + with pytest.raises(Leap0Error, match="boom"): + list( + CodeInterpreterClient(mock_transport, sandbox_domain="sandbox.example.com").execute_stream( + "sbx-1", + code="print('ok')", + ) + ) diff --git a/tests/_sync/test_desktop.py b/tests/_sync/test_desktop.py new file mode 100644 index 0000000..94d5a34 --- /dev/null +++ b/tests/_sync/test_desktop.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from leap0._sync.desktop import DesktopClient +from leap0.models.errors import Leap0Error + + +class TestDesktopClient: + def test_status_stream_raises_on_non_dict_event(self, mock_transport): + response = MagicMock() + response.iter_lines.return_value = iter(["data: malformed", ""]) + mock_transport.stream.return_value = response + + with pytest.raises(Leap0Error, match="Malformed desktop status stream event"): + list(DesktopClient(mock_transport, sandbox_domain="sandbox.example.com").status_stream("sbx-1")) diff --git a/tests/_sync/test_pty.py b/tests/_sync/test_pty.py index 23f9339..a689c73 100644 --- a/tests/_sync/test_pty.py +++ b/tests/_sync/test_pty.py @@ -1,5 +1,7 @@ from __future__ import annotations +from unittest.mock import patch + import pytest from leap0.models.pty import CreatePtySessionParams @@ -17,6 +19,15 @@ def test_create_builds_payload(self, mock_transport): assert kwargs["json"]["id"] == "sess" assert kwargs["json"]["cwd"] == "/workspace" + def test_resize_uses_resize_params(self, mock_transport): + mock_transport.request_json.return_value = {"id": "pty-1", "cols": 120, "rows": 30} + + with patch("leap0._sync.pty.PtyResizeParams") as resize_params: + resize_params.return_value.to_payload.return_value = {"cols": 120, "rows": 30} + PtyClient(mock_transport).resize("sbx-1", "pty-1", cols=120, rows=30) + + resize_params.assert_called_once_with(cols=120, rows=30) + class TestCreatePtySessionParams: def test_invalid_cols(self): diff --git a/tests/_sync/test_transport.py b/tests/_sync/test_transport.py index b7945aa..21e4b58 100644 --- a/tests/_sync/test_transport.py +++ b/tests/_sync/test_transport.py @@ -97,3 +97,22 @@ def test_relative(self, transport): class TestBaseUrlNormalization: def test_trailing_slash_stripped(self): assert Transport(api_key="k", base_url="https://api.example.com/").base_url == "https://api.example.com" + + +class TestTimeoutHandling: + def test_request_uses_zero_timeout(self, transport): + transport._client = MagicMock() + transport._client.request.return_value = MagicMock(spec=httpx.Response, status_code=200) + + transport.request("GET", "/test", timeout=0) + + assert transport._client.request.call_args.kwargs["timeout"] == 0 + + def test_request_uses_zero_override(self, transport): + transport._client = MagicMock() + transport._client.request.return_value = MagicMock(spec=httpx.Response, status_code=200) + + with transport.override_timeout(0): + transport.request("GET", "/test") + + assert transport._client.request.call_args.kwargs["timeout"] == 0 From cc4e0c770b57829be6222c8a6c37f9ab87dcbb6c Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Wed, 1 Apr 2026 15:17:51 -0400 Subject: [PATCH 5/6] fixed comments --- leap0/_async/filesystem.py | 20 +++++------- leap0/_sync/_transport.py | 3 +- leap0/_sync/code_interpreter.py | 58 +++++++-------------------------- leap0/_sync/desktop.py | 44 ++++++++++++++++++++----- leap0/_utils/errors.py | 3 +- leap0/_utils/otel.py | 15 ++++----- leap0/models/errors.py | 2 ++ tests/_sync/test_desktop.py | 31 +++++++++++++++++- tests/_sync/test_transport.py | 15 +++++++++ tests/models/test_errors.py | 10 ++++++ 10 files changed, 123 insertions(+), 78 deletions(-) diff --git a/leap0/_async/filesystem.py b/leap0/_async/filesystem.py index 267c904..3f310db 100644 --- a/leap0/_async/filesystem.py +++ b/leap0/_async/filesystem.py @@ -297,14 +297,16 @@ async def read_files_bytes( @intercept_errors("Failed to read files: ") async def read_files(self, sandbox: SandboxRef, *, paths: list[str], encoding: str = "utf-8", http_timeout: float | None = None) -> dict[str, str]: - """ - Read multiple files and return decoded text keyed by path. - - Args: - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - + """Read multiple files and return decoded text keyed by path. + + Args: + sandbox: Sandbox ID or object. + paths: File paths to read. + encoding: Text encoding used to decode each file. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + Returns: - object: Result returned by this operation. + dict[str, str]: Mapping of file path to decoded text content. """ return { path: content.decode(encoding) @@ -383,10 +385,6 @@ async def grep(self, sandbox: SandboxRef, *, path: str, pattern: str, include: s pattern: Text pattern to search for. include: File pattern filter (e.g. ``"*.py"``). exclude: Glob patterns to exclude. - - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - - Args: http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: diff --git a/leap0/_sync/_transport.py b/leap0/_sync/_transport.py index aa33d22..cda60c6 100644 --- a/leap0/_sync/_transport.py +++ b/leap0/_sync/_transport.py @@ -38,10 +38,9 @@ def __init__( self.timeout = timeout self.auth_header = auth_header self.bearer = bearer + self._timeout_override: ContextVar[float | None] = ContextVar("leap0_sync_timeout_override", default=None) self._client = httpx.Client(timeout=timeout) - _timeout_override: ContextVar[float | None] = ContextVar("leap0_sync_timeout_override", default=None) - @property def auth_value(self) -> str: """Return the formatted authorization header value. diff --git a/leap0/_sync/code_interpreter.py b/leap0/_sync/code_interpreter.py index 6667279..2dbc9f3 100644 --- a/leap0/_sync/code_interpreter.py +++ b/leap0/_sync/code_interpreter.py @@ -93,15 +93,6 @@ def health(self, sandbox: SandboxRef, http_timeout: float | None = None) -> bool sandbox: Sandbox ID or object. http_timeout: Optional HTTP request timeout in seconds for this SDK call. - Args: - sandbox: Sandbox ID or object. - language: Language runtime (e.g. ``"python"``, ``"typescript"``). - cwd: Working directory (default ``"/home/user"``). - - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - Returns: - CodeContext: Newly created persistent execution context. - Returns: bool: ``True`` when the service reports ``"ok"``. """ @@ -111,15 +102,15 @@ def health(self, sandbox: SandboxRef, http_timeout: float | None = None) -> bool @intercept_errors("Failed to create execution context: ") def create_context(self, sandbox: SandboxRef, *, language: str = "python", cwd: str | None = None, http_timeout: float | None = None) -> CodeContext: """Create a new execution context. - + Args: sandbox: Sandbox ID or object. - language: Language runtime for the operation. - cwd: Working directory for the operation. + language: Language runtime (default ``"python"``). + cwd: Working directory for the new context. http_timeout: Optional HTTP request timeout in seconds for this SDK call. - + Returns: - object: Result returned by this operation. + CodeContext: Newly created persistent execution context. """ payload: JsonObject = {"language": language} if cwd is not None: @@ -135,14 +126,6 @@ def list_contexts(self, sandbox: SandboxRef, http_timeout: float | None = None) sandbox: Sandbox ID or object. http_timeout: Optional HTTP request timeout in seconds for this SDK call. - Args: - sandbox: Sandbox ID or object. - context_id: Execution context ID. - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - - Returns: - CodeContext: Matching execution context. - Returns: list[CodeContext]: Active execution contexts. """ @@ -154,14 +137,14 @@ def list_contexts(self, sandbox: SandboxRef, http_timeout: float | None = None) @intercept_errors("Failed to get execution context: ") def get_context(self, sandbox: SandboxRef, context_id: str, http_timeout: float | None = None) -> CodeContext: """Get a single execution context by ID. - + Args: sandbox: Sandbox ID or object. - context_id: Parameter for this operation. + context_id: Execution context identifier. http_timeout: Optional HTTP request timeout in seconds for this SDK call. - + Returns: - object: Result returned by this operation. + CodeContext: Matching execution context. """ data = cast(CodeContextDict, self._request_json("GET", sandbox, f"/contexts/{context_id}", http_timeout=http_timeout)) return CodeContext.from_dict(data) @@ -169,10 +152,10 @@ def get_context(self, sandbox: SandboxRef, context_id: str, http_timeout: float @intercept_errors("Failed to delete execution context: ") def delete_context(self, sandbox: SandboxRef, context_id: str) -> None: """Delete an execution context. - + Args: sandbox: Sandbox ID or object. - context_id: Parameter for this operation. + context_id: Execution context identifier. """ self._request("DELETE", sandbox, f"/contexts/{context_id}", expected_status=204) @@ -198,27 +181,8 @@ def execute( Auto-generated if omitted. env_vars: Environment variables for the execution. timeout_ms: Execution timeout in milliseconds (default 30000). - - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - - Args: - sandbox: Sandbox ID or object. - code: Source code to execute. - language: Language runtime (default ``"python"``). - context_id: Link to an existing context to share state. - timeout_ms: Execution timeout in milliseconds (default 30000). - - Args: - sandbox: Sandbox ID or object. - code: Source code to execute. - language: Language runtime (default ``"python"``). - context_id: Link to an existing context to share state. - timeout_ms: Execution timeout in milliseconds (default 30000). http_timeout: Optional HTTP request timeout in seconds for this SDK call. - Yields: - StreamEvent: Streaming stdout, stderr, exit, and error events. - Returns: CodeExecutionResult: Structured execution output, errors, and logs. """ diff --git a/leap0/_sync/desktop.py b/leap0/_sync/desktop.py index 1a7771f..ae03f14 100644 --- a/leap0/_sync/desktop.py +++ b/leap0/_sync/desktop.py @@ -1,6 +1,8 @@ from __future__ import annotations import time +from queue import Empty, Queue +from threading import Thread from collections.abc import Iterator from typing import cast @@ -592,11 +594,39 @@ def status_stream(self, sandbox: SandboxRef, *, deadline: float | None = None, h response = self._transport.stream("GET", url, timeout=stream_timeout) try: events = iter_sse_events(response.iter_lines()) + + def _next_event(timeout: float | None) -> object: + result: Queue[tuple[str, object]] = Queue(maxsize=1) + + def _read_event() -> None: + try: + result.put(("event", next(events))) + except StopIteration as exc: + result.put(("stop", exc)) + except BaseException as exc: # pragma: no cover - passthrough guard + result.put(("error", exc)) + + reader = Thread(target=_read_event, daemon=True) + reader.start() + try: + kind, value = result.get(timeout=timeout) + except Empty as exc: + raise Leap0TimeoutError("Desktop status stream timed out") from exc + if kind == "event": + return value + if kind == "stop": + raise cast(StopIteration, value) + raise cast(BaseException, value) + while True: - if deadline is not None and time.monotonic() >= deadline: - raise Leap0TimeoutError("Desktop status stream timed out") + read_timeout = http_timeout + if deadline is not None: + remaining_time = deadline - time.monotonic() + if remaining_time <= 0: + raise Leap0TimeoutError("Desktop status stream timed out") + read_timeout = remaining_time if read_timeout is None else min(read_timeout, remaining_time) try: - event = next(events) + event = _next_event(read_timeout) except StopIteration: break if not isinstance(event, dict): @@ -633,10 +663,8 @@ def wait_until_ready(self, sandbox: SandboxRef, *, timeout: float = 60.0, http_t """ def _is_transient_leap0(exc: BaseException) -> bool: - """Return True for transient Leap0Errors (not timeouts).""" - if isinstance(exc, Leap0TimeoutError): - return False - return isinstance(exc, Leap0Error) + """Return True only for retryable Leap0 errors.""" + return isinstance(exc, Leap0Error) and not isinstance(exc, Leap0TimeoutError) and exc.retryable deadline = time.monotonic() + timeout @@ -650,7 +678,7 @@ def _poll() -> None: for status in self.status_stream(sandbox, deadline=deadline, http_timeout=http_timeout): if status.status == "running": return - raise Leap0Error("Desktop status stream ended without reaching 'running' state") + raise Leap0Error("Desktop status stream ended without reaching 'running' state", retryable=True) try: _poll() diff --git a/leap0/_utils/errors.py b/leap0/_utils/errors.py index ebd7bac..f2500f2 100644 --- a/leap0/_utils/errors.py +++ b/leap0/_utils/errors.py @@ -33,6 +33,7 @@ def _raise_processed(prefix: str, exc: Exception) -> NoReturn: status_code=exc.status_code, headers=exc.headers, body=exc.body, + retryable=getattr(exc, "retryable", False), ) from None try: @@ -44,7 +45,7 @@ def _raise_processed(prefix: str, exc: Exception) -> NoReturn: if isinstance(exc, _httpx.TimeoutException): raise Leap0TimeoutError(_prefixed_message(str(exc), prefix)) from None if isinstance(exc, (_httpx.ConnectError, _httpx.NetworkError)): - raise Leap0Error(_prefixed_message(str(exc), prefix)) from None + raise Leap0Error(_prefixed_message(str(exc), prefix), retryable=True) from None if isinstance(exc, RuntimeError): lowered = str(exc).lower() diff --git a/leap0/_utils/otel.py b/leap0/_utils/otel.py index 7fbc572..7e1b3cb 100644 --- a/leap0/_utils/otel.py +++ b/leap0/_utils/otel.py @@ -5,7 +5,6 @@ import threading import time from collections.abc import Callable -from contextlib import AbstractContextManager from typing import ParamSpec, Protocol, TypeVar, cast from opentelemetry import metrics, trace @@ -58,7 +57,7 @@ def __exit__(self, exc_type: object, exc: object, tb: object) -> bool | None: class _TracerProtocol(Protocol): """Protocol for the OpenTelemetry tracer used by the SDK.""" - def start_as_current_span(self, name: str) -> AbstractContextManager[_SpanProtocol]: + def start_as_current_span(self, name: str) -> _SpanContextManagerProtocol: """Create a context manager that activates a span.""" ... @@ -125,17 +124,17 @@ async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: tracer = get_tracer() histogram = _get_histogram(name) - start = time.time() + start = time.perf_counter() with tracer.start_as_current_span(name) as span: try: result = await func(*args, **kwargs) span.set_status(Status(StatusCode.OK)) - histogram.record((time.time() - start) * 1000, {"status": "success"}) + histogram.record((time.perf_counter() - start) * 1000, {"status": "success"}) return result except Exception as exc: span.set_status(Status(StatusCode.ERROR, str(exc))) span.record_exception(exc) - histogram.record((time.time() - start) * 1000, {"status": "error"}) + histogram.record((time.perf_counter() - start) * 1000, {"status": "error"}) raise return cast(Callable[P, R], async_wrapper) @@ -145,17 +144,17 @@ def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: tracer = get_tracer() histogram = _get_histogram(name) - start = time.time() + start = time.perf_counter() with tracer.start_as_current_span(name) as span: try: result = func(*args, **kwargs) span.set_status(Status(StatusCode.OK)) - histogram.record((time.time() - start) * 1000, {"status": "success"}) + histogram.record((time.perf_counter() - start) * 1000, {"status": "success"}) return result except Exception as exc: span.set_status(Status(StatusCode.ERROR, str(exc))) span.record_exception(exc) - histogram.record((time.time() - start) * 1000, {"status": "error"}) + histogram.record((time.perf_counter() - start) * 1000, {"status": "error"}) raise return cast(Callable[P, R], sync_wrapper) diff --git a/leap0/models/errors.py b/leap0/models/errors.py index 6b92577..055d8cb 100644 --- a/leap0/models/errors.py +++ b/leap0/models/errors.py @@ -24,11 +24,13 @@ def __init__( headers: Mapping[str, Any] | None = None, *, body: str | None = None, + retryable: bool = False, ): self.message = message self.status_code: int | None = status_code self.headers: dict[str, Any] = dict(headers or {}) self.body: str | None = body + self.retryable = retryable self.error_message: str | None = None if body: try: diff --git a/tests/_sync/test_desktop.py b/tests/_sync/test_desktop.py index 94d5a34..1e5d82c 100644 --- a/tests/_sync/test_desktop.py +++ b/tests/_sync/test_desktop.py @@ -5,7 +5,7 @@ import pytest from leap0._sync.desktop import DesktopClient -from leap0.models.errors import Leap0Error +from leap0.models.errors import Leap0Error, Leap0TimeoutError class TestDesktopClient: @@ -16,3 +16,32 @@ def test_status_stream_raises_on_non_dict_event(self, mock_transport): with pytest.raises(Leap0Error, match="Malformed desktop status stream event"): list(DesktopClient(mock_transport, sandbox_domain="sandbox.example.com").status_stream("sbx-1")) + + def test_wait_until_ready_retries_only_retryable_errors(self, mock_transport): + first = MagicMock() + first.iter_lines.return_value = iter([]) + second = MagicMock() + second.iter_lines.return_value = iter([ + 'data: {"status": "running", "items": []}', + "", + ]) + mock_transport.stream.side_effect = [first, second] + + DesktopClient(mock_transport, sandbox_domain="sandbox.example.com").wait_until_ready("sbx-1", timeout=1) + + assert mock_transport.stream.call_count == 2 + + def test_wait_until_ready_stops_on_malformed_stream(self, mock_transport): + bad = MagicMock() + bad.iter_lines.return_value = iter(["data: malformed", ""]) + good = MagicMock() + good.iter_lines.return_value = iter([ + 'data: {"status": "running", "items": []}', + "", + ]) + mock_transport.stream.side_effect = [bad, good] + + with pytest.raises(Leap0TimeoutError, match="Malformed desktop status stream event"): + DesktopClient(mock_transport, sandbox_domain="sandbox.example.com").wait_until_ready("sbx-1", timeout=1) + + assert mock_transport.stream.call_count == 1 diff --git a/tests/_sync/test_transport.py b/tests/_sync/test_transport.py index 21e4b58..4efe0e7 100644 --- a/tests/_sync/test_transport.py +++ b/tests/_sync/test_transport.py @@ -116,3 +116,18 @@ def test_request_uses_zero_override(self, transport): transport.request("GET", "/test") assert transport._client.request.call_args.kwargs["timeout"] == 0 + + def test_timeout_override_is_instance_scoped(self): + first = Transport(api_key="k1", base_url="https://api.example.com") + second = Transport(api_key="k2", base_url="https://api.example.com") + first._client = MagicMock() + second._client = MagicMock() + first._client.request.return_value = MagicMock(spec=httpx.Response, status_code=200) + second._client.request.return_value = MagicMock(spec=httpx.Response, status_code=200) + + with first.override_timeout(1.5): + first.request("GET", "/first") + second.request("GET", "/second") + + assert first._client.request.call_args.kwargs["timeout"] == 1.5 + assert second._client.request.call_args.kwargs["timeout"] == second.timeout diff --git a/tests/models/test_errors.py b/tests/models/test_errors.py index 3f0046a..26d3055 100644 --- a/tests/models/test_errors.py +++ b/tests/models/test_errors.py @@ -45,6 +45,16 @@ def failing(): with pytest.raises(Leap0Error) as exc_info: failing() assert type(exc_info.value) is Leap0Error + assert exc_info.value.retryable is True + + def test_retryable_flag_is_preserved(self): + @intercept_errors("Failed to create sandbox: ") + def failing(): + raise Leap0Error("temporary failure", retryable=True) + + with pytest.raises(Leap0Error) as exc_info: + failing() + assert exc_info.value.retryable is True def test_generic_exception(self): @intercept_errors("Failed: ") From 5e65c9c1aa9fae990170ca69bd466cbb04268825 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Wed, 1 Apr 2026 15:42:38 -0400 Subject: [PATCH 6/6] fixed comments --- leap0/_sync/code_interpreter.py | 16 ++++++++++++++-- leap0/_sync/desktop.py | 8 +++++--- leap0/models/errors.py | 3 ++- tests/_sync/test_code_interpreter.py | 13 +++++++++++++ tests/_sync/test_desktop.py | 4 ++-- tests/models/test_errors.py | 16 +++++++++++++++- 6 files changed, 51 insertions(+), 9 deletions(-) diff --git a/leap0/_sync/code_interpreter.py b/leap0/_sync/code_interpreter.py index 2dbc9f3..72ca010 100644 --- a/leap0/_sync/code_interpreter.py +++ b/leap0/_sync/code_interpreter.py @@ -150,14 +150,26 @@ def get_context(self, sandbox: SandboxRef, context_id: str, http_timeout: float return CodeContext.from_dict(data) @intercept_errors("Failed to delete execution context: ") - def delete_context(self, sandbox: SandboxRef, context_id: str) -> None: + def delete_context( + self, + sandbox: SandboxRef, + context_id: str, + http_timeout: float | None = None, + ) -> None: """Delete an execution context. Args: sandbox: Sandbox ID or object. context_id: Execution context identifier. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. """ - self._request("DELETE", sandbox, f"/contexts/{context_id}", expected_status=204) + self._request( + "DELETE", + sandbox, + f"/contexts/{context_id}", + expected_status=204, + http_timeout=http_timeout, + ) @intercept_errors("Failed to execute code: ") def execute( diff --git a/leap0/_sync/desktop.py b/leap0/_sync/desktop.py index ae03f14..c9ff346 100644 --- a/leap0/_sync/desktop.py +++ b/leap0/_sync/desktop.py @@ -687,6 +687,8 @@ def _poll() -> None: 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}" - ) from exc + if exc.retryable: + raise Leap0TimeoutError( + f"Desktop did not become ready within {timeout:.0f}s: {exc}" + ) from exc + raise diff --git a/leap0/models/errors.py b/leap0/models/errors.py index 055d8cb..a76f3b6 100644 --- a/leap0/models/errors.py +++ b/leap0/models/errors.py @@ -106,4 +106,5 @@ def raise_api_error( still branch on it when needed. """ cls = _STATUS_TO_EXCEPTION.get(status_code, Leap0Error) - raise cls(message, status_code, headers, body=body) + retryable = status_code == 429 or 500 <= status_code < 600 + raise cls(message, status_code, headers, body=body, retryable=retryable) diff --git a/tests/_sync/test_code_interpreter.py b/tests/_sync/test_code_interpreter.py index 8c4380c..80c07b3 100644 --- a/tests/_sync/test_code_interpreter.py +++ b/tests/_sync/test_code_interpreter.py @@ -43,3 +43,16 @@ def test_execute_stream_raises_on_error_envelope(self, mock_transport): code="print('ok')", ) ) + + def test_delete_context_passes_http_timeout(self, mock_transport): + client = CodeInterpreterClient(mock_transport, sandbox_domain="sandbox.example.com") + + client.delete_context("sbx-1", "ctx-1", http_timeout=12.5) + + mock_transport.request_target.assert_called_once_with( + "DELETE", + "https://sbx-1.sandbox.example.com/contexts/ctx-1", + json=None, + expected_status=204, + timeout=12.5, + ) diff --git a/tests/_sync/test_desktop.py b/tests/_sync/test_desktop.py index 1e5d82c..b0e064f 100644 --- a/tests/_sync/test_desktop.py +++ b/tests/_sync/test_desktop.py @@ -5,7 +5,7 @@ import pytest from leap0._sync.desktop import DesktopClient -from leap0.models.errors import Leap0Error, Leap0TimeoutError +from leap0.models.errors import Leap0Error class TestDesktopClient: @@ -41,7 +41,7 @@ def test_wait_until_ready_stops_on_malformed_stream(self, mock_transport): ]) mock_transport.stream.side_effect = [bad, good] - with pytest.raises(Leap0TimeoutError, match="Malformed desktop status stream event"): + with pytest.raises(Leap0Error, match="Malformed desktop status stream event"): DesktopClient(mock_transport, sandbox_domain="sandbox.example.com").wait_until_ready("sbx-1", timeout=1) assert mock_transport.stream.call_count == 1 diff --git a/tests/models/test_errors.py b/tests/models/test_errors.py index 26d3055..55f8ee7 100644 --- a/tests/models/test_errors.py +++ b/tests/models/test_errors.py @@ -3,7 +3,7 @@ import httpx import pytest -from leap0.models.errors import Leap0Error, Leap0NotFoundError, Leap0TimeoutError +from leap0.models.errors import Leap0Error, Leap0NotFoundError, Leap0TimeoutError, raise_api_error from leap0._utils.errors import intercept_errors @@ -88,3 +88,17 @@ def ok(): return 42 assert ok() == 42 + + +class TestRaiseApiError: + def test_marks_rate_limits_retryable(self): + with pytest.raises(Leap0Error) as exc_info: + raise_api_error(429, "Too many requests") + + assert exc_info.value.retryable is True + + def test_marks_server_errors_retryable(self): + with pytest.raises(Leap0Error) as exc_info: + raise_api_error(503, "Service unavailable") + + assert exc_info.value.retryable is True