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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/daily.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ jobs:
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- run: pytest
- run: hatch run test
4 changes: 2 additions & 2 deletions .github/workflows/python-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[dev]"
- run: ruff check .
- run: pytest
- run: hatch run lint
- run: hatch run unit-tests
- name: upload coverage
if: success() && matrix.python-version == '3.12' && github.actor != 'dependabot[bot]'
uses: codecov/codecov-action@v4
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,24 @@ sandbox = await Sandbox.create(
},
)
```

## Development

Install development dependencies:

```bash
pip install -e ".[dev]"
```

To run the same checks used by CI:

```bash
hatch run test
```

Linting is powered by Ruff:

```bash
hatch run lint
hatch run lint-fix
```
38 changes: 38 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,55 @@ packages = ["src/aio_lib_sandbox"]

[project.optional-dependencies]
dev = [
"hatch>=1.14",
"pytest>=8",
"pytest-asyncio>=0.24",
"pytest-cov>=5",
"ruff>=0.8",
]

[tool.hatch.envs.default]
features = ["dev"]

[tool.hatch.envs.default.scripts]
lint = [
"ruff check src tests",
"ruff format --check src tests",
]
lint-fix = [
"ruff check src tests --fix",
"ruff format src tests",
]
unit-tests = "pytest"
test = [
"pytest",
"ruff check src tests",
"ruff format --check src tests",
]

[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
addopts = "--cov=src/aio_lib_sandbox --cov-report=xml --cov-report=term-missing"

[tool.ruff]
target-version = "py310"
line-length = 120
src = ["src", "tests"]

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes
"I", # import sorting
"B", # flake8-bugbear
"ASYNC", # async-specific checks
]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"

[tool.coverage.run]
source = ["src/aio_lib_sandbox"]

Expand Down
2 changes: 2 additions & 0 deletions src/aio_lib_sandbox/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ class SandboxWebSocketError(SandboxSDKError):
class SandboxCommandNotFoundError(SandboxSDKError):
"""Raised by ``get_command()`` when no running process matches the provided exec_id."""


class SandboxPortNotProvisionedError(SandboxClientError):
"""Port was not declared in ``create(ports=[...])`` and cannot be retrieved."""


class SandboxInvalidPortError(SandboxClientError):
"""Port value is not a valid integer in the range 1–65535."""
4 changes: 1 addition & 3 deletions src/aio_lib_sandbox/frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@ def is_auth_ack(frame: dict[str, Any] | None, sandbox_id: str) -> bool:
"""Return True if the frame is a successful auth acknowledgement for this sandbox."""
if frame is None:
return False
return frame.get("type") == "auth.ok" and (
not frame.get("sandboxId") or frame["sandboxId"] == sandbox_id
)
return frame.get("type") == "auth.ok" and (not frame.get("sandboxId") or frame["sandboxId"] == sandbox_id)


def normalize_size(size: str | dict[str, Any] | None) -> str:
Expand Down
40 changes: 14 additions & 26 deletions src/aio_lib_sandbox/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@

import httpx

from .errors import (
SandboxClientError,
SandboxInitializationError,
SandboxInvalidPortError,
SandboxPortNotProvisionedError,
SandboxWebSocketError,
)
from .frames import normalize_size
from .http import (
api_request,
Expand All @@ -19,14 +26,6 @@
normalize_api_host,
sandbox_http_error,
)
from .ws import PendingExec, PendingFileOp, PendingGetOp, WsSession
from .errors import (
SandboxClientError,
SandboxInitializationError,
SandboxInvalidPortError,
SandboxPortNotProvisionedError,
SandboxWebSocketError,
)
from .types import (
SANDBOX_SIZES,
DetachedCommandHandle,
Expand All @@ -35,6 +34,7 @@
Policy,
WriteResult,
)
from .ws import PendingExec, PendingFileOp, PendingGetOp, WsSession


class Sandbox:
Expand Down Expand Up @@ -162,9 +162,7 @@ async def create(
)

sandbox_id = payload["sandboxId"]
endpoint = payload.get("wsEndpoint") or build_ws_endpoint(
creds["api_host"], creds["namespace"], sandbox_id
)
endpoint = payload.get("wsEndpoint") or build_ws_endpoint(creds["api_host"], creds["namespace"], sandbox_id)

sandbox = cls(
sandbox_id=sandbox_id,
Expand Down Expand Up @@ -287,9 +285,7 @@ def exec(
self.ensure_open()

if detached and timeout is not None:
raise SandboxClientError(
"timeout is not supported with detached=True"
)
raise SandboxClientError("timeout is not supported with detached=True")

exec_id = f"exec-{secrets.token_hex(12)}"
loop = asyncio.get_running_loop()
Expand Down Expand Up @@ -417,12 +413,8 @@ async def write_file(self, path: str, content: str | bytes) -> WriteResult:
Returns:
A :class:`WriteResult` confirmation.
"""
encoded = base64.b64encode(
content if isinstance(content, bytes) else content.encode()
).decode()
return await self.file_op(
"file.write", path=path, content=encoded, encoding="base64"
)
encoded = base64.b64encode(content if isinstance(content, bytes) else content.encode()).decode()
return await self.file_op("file.write", path=path, content=encoded, encoding="base64")

async def list_files(self, path: str) -> list[FileEntry]:
"""List the contents of a directory inside the sandbox.
Expand Down Expand Up @@ -465,9 +457,7 @@ def get_url(self, port: int) -> str:
declared in ``create(ports=[...])``.
"""
if not isinstance(port, int) or port < 1 or port > 65535:
raise SandboxInvalidPortError(
f"Invalid port '{port}': must be an integer between 1 and 65535"
)
raise SandboxInvalidPortError(f"Invalid port '{port}': must be an integer between 1 and 65535")

url = self.preview_urls.get(port)
if url is None:
Expand Down Expand Up @@ -498,9 +488,7 @@ async def destroy(self) -> dict[str, Any]:
try:
resp = await client.delete(url, headers=headers)
except httpx.HTTPError as exc:
raise SandboxClientError(
f"Could not destroy sandbox '{self.id}': {exc}"
) from exc
raise SandboxClientError(f"Could not destroy sandbox '{self.id}': {exc}") from exc

if not resp.is_success:
msg = resp.text
Expand Down
49 changes: 14 additions & 35 deletions src/aio_lib_sandbox/ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@

import websockets

from .frames import is_auth_ack, parse_frame
from .errors import (
SandboxClientError,
SandboxCommandNotFoundError,
SandboxTimeoutError,
SandboxUnauthorizedError,
SandboxWebSocketError,
)
from .frames import is_auth_ack, parse_frame
from .types import DetachedCommandHandle, ExecResult, FileEntry, WriteResult

logger = logging.getLogger("aio_lib_sandbox")
Expand All @@ -47,7 +47,7 @@ class PendingExec:
timeout_handle: asyncio.TimerHandle | None = None
# Detached-process fields
detached: bool = False
resolved: bool = False # True once the outer future has been set (exec.detached)
resolved: bool = False # True once the outer future has been set (exec.detached)
wait_future: asyncio.Future[Any] | None = None # resolves on exec.exit for detached


Expand Down Expand Up @@ -121,9 +121,7 @@ async def connect(self) -> None:
ssl=ssl_ctx,
)
except Exception as exc:
raise SandboxWebSocketError(
f"Could not connect sandbox '{self.id}': {exc}"
) from exc
raise SandboxWebSocketError(f"Could not connect sandbox '{self.id}': {exc}") from exc

self.ws = ws
await self.authenticate()
Expand All @@ -134,9 +132,7 @@ async def authenticate(self) -> None:
raw = await self.ws.recv()
frame = parse_frame(raw)
if not is_auth_ack(frame, self.id):
raise SandboxUnauthorizedError(
f"Sandbox '{self.id}' rejected the WebSocket authentication token"
)
raise SandboxUnauthorizedError(f"Sandbox '{self.id}' rejected the WebSocket authentication token")

async def send_frame(self, frame: dict[str, Any]) -> None:
"""Serialise ``frame`` and send it over the socket."""
Expand Down Expand Up @@ -183,9 +179,7 @@ def register_file_op(self, exec_id: str, pending: PendingFileOp) -> None:
def register_get_op(self, exec_id: str, pending: PendingGetOp) -> None:
self.pending_get_ops[exec_id] = pending

def reject_pending(
self, store: dict[str, Any], exec_id: str, error: Exception
) -> None:
def reject_pending(self, store: dict[str, Any], exec_id: str, error: Exception) -> None:
pending = store.pop(exec_id, None)
if pending is None:
return
Expand Down Expand Up @@ -231,9 +225,7 @@ def resolve_exec_on_intentional_close(self, exec_id: str) -> None:
if pending.detached:
if not pending.future.done():
pending.resolved = True
pending.future.set_result(
{"pid": None, "started_at": None, "destroyed": True}
)
pending.future.set_result({"pid": None, "started_at": None, "destroyed": True})
if pending.wait_future and not pending.wait_future.done():
pending.wait_future.set_result(wait_result)
return
Expand Down Expand Up @@ -277,9 +269,7 @@ def timeout_exec(self, exec_id: str, command: str, timeout: float) -> None:
self.reject_pending(
self.pending_execs,
exec_id,
SandboxTimeoutError(
f"Command '{command}' exceeded timeout of {timeout}ms"
),
SandboxTimeoutError(f"Command '{command}' exceeded timeout of {timeout}ms"),
)

# ------------------------------------------------------------------
Expand All @@ -297,9 +287,7 @@ async def listen(self) -> None:

# exec.info is always routed to pending_get_ops.
# Error frames for pending get ops also go there (before exec map check).
if ftype == "exec.info" or (
ftype == "error" and exec_id in self.pending_get_ops
):
if ftype == "exec.info" or (ftype == "error" and exec_id in self.pending_get_ops):
self.handle_get_frame(frame)
elif exec_id in self.pending_file_ops:
self.handle_file_frame(frame)
Expand All @@ -310,11 +298,7 @@ async def listen(self) -> None:
self.resolve_all_on_intentional_close()
return
close_code = exc.rcvd.code if exc.rcvd is not None else 1006
self.reject_all(
SandboxWebSocketError(
f"Sandbox '{self.id}' WebSocket closed with code {close_code}"
)
)
self.reject_all(SandboxWebSocketError(f"Sandbox '{self.id}' WebSocket closed with code {close_code}"))
finally:
self.ws = None
if self.intentional_close:
Expand Down Expand Up @@ -409,19 +393,14 @@ def handle_file_frame(self, frame: dict[str, Any]) -> None:
)

elif ftype == "file.entries":
entries = [
FileEntry(name=e["name"], type=e["type"], size=e.get("size"))
for e in frame.get("entries", [])
]
entries = [FileEntry(name=e["name"], type=e["type"], size=e.get("size")) for e in frame.get("entries", [])]
self.resolve_file_op(exec_id, entries)

elif ftype == "error":
self.reject_pending(
self.pending_file_ops,
exec_id,
SandboxClientError(
frame.get("message", f"File operation '{exec_id}' failed")
),
SandboxClientError(frame.get("message", f"File operation '{exec_id}' failed")),
)

# ------------------------------------------------------------------
Expand Down Expand Up @@ -460,9 +439,7 @@ def reject_get_op(self, frame: dict[str, Any], pending: PendingGetOp) -> None:
self.pending_get_ops.pop(exec_id, None)
if not pending.future.done():
pending.future.set_exception(
SandboxCommandNotFoundError(
frame.get("message", f"No running process for execId '{exec_id}'")
)
SandboxCommandNotFoundError(frame.get("message", f"No running process for execId '{exec_id}'"))
)

def resolve_exec_entry(self, frame: dict[str, Any], pending: PendingGetOp) -> "asyncio.Future[Any]":
Expand All @@ -485,10 +462,12 @@ def merge_on_output_callback(
if not on_output:
return
prev = existing.on_output

def merged(data: str, stream: str, _prev=prev, _new=on_output) -> None:
if _prev:
_prev(data, stream)
_new(data, stream)

existing.on_output = merged

def register_reattached_exec(
Expand Down
Loading
Loading