diff --git a/.github/workflows/daily.yml b/.github/workflows/daily.yml index 87b55f3..a7e9db3 100644 --- a/.github/workflows/daily.yml +++ b/.github/workflows/daily.yml @@ -14,4 +14,4 @@ jobs: with: python-version: "3.12" - run: pip install -e ".[dev]" - - run: pytest + - run: hatch run test diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 13f249d..e5e5bac 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -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 diff --git a/README.md b/README.md index 4c3f570..a89004c 100644 --- a/README.md +++ b/README.md @@ -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 +``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d77a6c7..d742704 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/aio_lib_sandbox/errors.py b/src/aio_lib_sandbox/errors.py index 89f93f0..7d146fb 100644 --- a/src/aio_lib_sandbox/errors.py +++ b/src/aio_lib_sandbox/errors.py @@ -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.""" diff --git a/src/aio_lib_sandbox/frames.py b/src/aio_lib_sandbox/frames.py index 02beb9d..3636f4f 100644 --- a/src/aio_lib_sandbox/frames.py +++ b/src/aio_lib_sandbox/frames.py @@ -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: diff --git a/src/aio_lib_sandbox/sandbox.py b/src/aio_lib_sandbox/sandbox.py index e4f142b..3e89ed5 100644 --- a/src/aio_lib_sandbox/sandbox.py +++ b/src/aio_lib_sandbox/sandbox.py @@ -11,6 +11,13 @@ import httpx +from .errors import ( + SandboxClientError, + SandboxInitializationError, + SandboxInvalidPortError, + SandboxPortNotProvisionedError, + SandboxWebSocketError, +) from .frames import normalize_size from .http import ( api_request, @@ -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, @@ -35,6 +34,7 @@ Policy, WriteResult, ) +from .ws import PendingExec, PendingFileOp, PendingGetOp, WsSession class Sandbox: @@ -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, @@ -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() @@ -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. @@ -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: @@ -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 diff --git a/src/aio_lib_sandbox/ws.py b/src/aio_lib_sandbox/ws.py index 062cfbd..8f5c47e 100644 --- a/src/aio_lib_sandbox/ws.py +++ b/src/aio_lib_sandbox/ws.py @@ -19,7 +19,6 @@ import websockets -from .frames import is_auth_ack, parse_frame from .errors import ( SandboxClientError, SandboxCommandNotFoundError, @@ -27,6 +26,7 @@ SandboxUnauthorizedError, SandboxWebSocketError, ) +from .frames import is_auth_ack, parse_frame from .types import DetachedCommandHandle, ExecResult, FileEntry, WriteResult logger = logging.getLogger("aio_lib_sandbox") @@ -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 @@ -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() @@ -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.""" @@ -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 @@ -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 @@ -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"), ) # ------------------------------------------------------------------ @@ -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) @@ -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: @@ -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")), ) # ------------------------------------------------------------------ @@ -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]": @@ -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( diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index a014b81..875f823 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -32,8 +32,8 @@ SandboxWebSocketError, ) from aio_lib_sandbox.frames import normalize_size -from aio_lib_sandbox.ws import PendingExec, PendingFileOp, PendingGetOp, WsSession from aio_lib_sandbox.sandbox import _parse_preview_urls +from aio_lib_sandbox.ws import PendingExec, PendingFileOp, PendingGetOp, WsSession # --------------------------------------------------------------------------- # Helpers @@ -153,9 +153,7 @@ def test_explicit_overrides_env(self, monkeypatch): assert creds["api_key"] == "explicit-key" def test_prepends_https(self): - creds = Sandbox.resolve_credentials( - api_host="host.example.net", namespace="ns", auth="key" - ) + creds = Sandbox.resolve_credentials(api_host="host.example.net", namespace="ns", auth="key") assert creds["api_host"] == "https://host.example.net" def test_missing_credentials_raise(self): @@ -198,8 +196,10 @@ async def test_create_calls_api_and_connects(self, monkeypatch): }, } - with patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)) as mock_req, \ - patch.object(Sandbox, "connect", new=AsyncMock()) as mock_connect: + with ( + patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)) as mock_req, + patch.object(Sandbox, "connect", new=AsyncMock()) as mock_connect, + ): sandbox = await Sandbox.create( name="my-sandbox", api_host="https://runtime.example.net", @@ -226,8 +226,10 @@ async def test_create_forwards_policy(self, monkeypatch): "maxLifetime": 3600, } - with patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)) as mock_req, \ - patch.object(Sandbox, "connect", new=AsyncMock()): + with ( + patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)) as mock_req, + patch.object(Sandbox, "connect", new=AsyncMock()), + ): await Sandbox.create( name="policy-sb", api_host="https://runtime.example.net", @@ -253,8 +255,10 @@ async def test_create_reads_env_vars(self, monkeypatch): "maxLifetime": 3600, } - with patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)), \ - patch.object(Sandbox, "connect", new=AsyncMock()): + with ( + patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)), + patch.object(Sandbox, "connect", new=AsyncMock()), + ): sandbox = await Sandbox.create(name="env-sandbox") assert sandbox.id == "sb-env" @@ -273,8 +277,10 @@ async def test_create_builds_ws_endpoint_when_absent(self, monkeypatch): "maxLifetime": 3600, } - with patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)), \ - patch.object(Sandbox, "connect", new=AsyncMock()): + with ( + patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)), + patch.object(Sandbox, "connect", new=AsyncMock()), + ): sandbox = await Sandbox.create( name="no-endpoint", api_host="https://runtime.example.net", @@ -300,8 +306,10 @@ async def test_create_forwards_ports_and_parses_preview_urls(self): }, } - with patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)) as mock_req, \ - patch.object(Sandbox, "connect", new=AsyncMock()): + with ( + patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)) as mock_req, + patch.object(Sandbox, "connect", new=AsyncMock()), + ): sandbox = await Sandbox.create( name="ports-sandbox", api_host="https://runtime.example.net", @@ -338,9 +346,11 @@ async def test_connect_opens_socket_authenticates_and_starts_listener(self): async def noop_listen(): return None - with patch("aio_lib_sandbox.ws.websockets.connect", new=AsyncMock(return_value=ws)) as connect, \ - patch.object(session, "authenticate", new=AsyncMock()) as authenticate, \ - patch.object(session, "listen", new=noop_listen): + with ( + patch("aio_lib_sandbox.ws.websockets.connect", new=AsyncMock(return_value=ws)) as connect, + patch.object(session, "authenticate", new=AsyncMock()) as authenticate, + patch.object(session, "listen", new=noop_listen), + ): await session.connect() await session.listener_task @@ -391,9 +401,7 @@ async def test_authenticate_rejects_non_ack_frame(self): with pytest.raises(SandboxUnauthorizedError, match="rejected"): await session.authenticate() - session.ws.send.assert_awaited_once_with( - json.dumps({"type": "auth", "token": "tok-abc"}) - ) + session.ws.send.assert_awaited_once_with(json.dumps({"type": "auth", "token": "tok-abc"})) def test_ensure_open_raises_when_socket_missing(self): session = WsSession( @@ -419,30 +427,18 @@ async def test_listen_routes_frames_and_clears_socket(self): json.dumps({"type": "file.content", "execId": "file-1"}), json.dumps({"type": "exec.output", "execId": "exec-1"}), ) - session.pending_get_ops["get-1"] = PendingGetOp( - future=asyncio.get_running_loop().create_future() - ) - session.pending_file_ops["file-1"] = PendingFileOp( - future=asyncio.get_running_loop().create_future() - ) - session.pending_execs["exec-1"] = PendingExec( - future=asyncio.get_running_loop().create_future() - ) + session.pending_get_ops["get-1"] = PendingGetOp(future=asyncio.get_running_loop().create_future()) + session.pending_file_ops["file-1"] = PendingFileOp(future=asyncio.get_running_loop().create_future()) + session.pending_execs["exec-1"] = PendingExec(future=asyncio.get_running_loop().create_future()) session.handle_get_frame = MagicMock() session.handle_file_frame = MagicMock() session.handle_exec_frame = MagicMock() await session.listen() - session.handle_get_frame.assert_called_once_with( - {"type": "exec.info", "execId": "get-1"} - ) - session.handle_file_frame.assert_called_once_with( - {"type": "file.content", "execId": "file-1"} - ) - session.handle_exec_frame.assert_called_once_with( - {"type": "exec.output", "execId": "exec-1"} - ) + session.handle_get_frame.assert_called_once_with({"type": "exec.info", "execId": "get-1"}) + session.handle_file_frame.assert_called_once_with({"type": "file.content", "execId": "file-1"}) + session.handle_exec_frame.assert_called_once_with({"type": "exec.output", "execId": "exec-1"}) assert session.ws is None @pytest.mark.asyncio @@ -515,6 +511,27 @@ async def test_get_returns_sandbox_with_status(self): assert sandbox.cluster == "cluster-b" assert sandbox.session is None + @pytest.mark.asyncio + async def test_get_parses_preview_urls(self): + payload = { + "sandboxId": "sb-get", + "status": "running", + "previewUrls": { + "3000": "https://sb-get-3000.preview.example.net", + }, + } + + with patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)): + sandbox = await Sandbox.get( + "sb-get", + api_host="https://runtime.example.net", + namespace="ns", + auth="uuid:key", + ) + + assert sandbox.preview_urls == {3000: "https://sb-get-3000.preview.example.net"} + assert sandbox.get_url(3000) == "https://sb-get-3000.preview.example.net" + @pytest.mark.asyncio async def test_get_not_found_raises(self): with patch( @@ -564,9 +581,7 @@ async def test_exec_resolves_with_result(self): sandbox.session.handle_exec_frame( {"type": "exec.output", "execId": exec_id, "stream": "stdout", "data": "hello\n"} ) - sandbox.session.handle_exec_frame( - {"type": "exec.exit", "execId": exec_id, "exitCode": 0} - ) + sandbox.session.handle_exec_frame({"type": "exec.exit", "execId": exec_id, "exitCode": 0}) result = await task assert result.stdout == "hello\n" @@ -600,12 +615,8 @@ async def test_exec_calls_on_output_callback(self): exec_id = task.exec_id await asyncio.sleep(0) - sandbox.session.handle_exec_frame( - {"type": "exec.output", "execId": exec_id, "stream": "stdout", "data": "a"} - ) - sandbox.session.handle_exec_frame( - {"type": "exec.output", "execId": exec_id, "stream": "stderr", "data": "b"} - ) + sandbox.session.handle_exec_frame({"type": "exec.output", "execId": exec_id, "stream": "stdout", "data": "a"}) + sandbox.session.handle_exec_frame({"type": "exec.output", "execId": exec_id, "stream": "stderr", "data": "b"}) sandbox.session.handle_exec_frame({"type": "exec.exit", "execId": exec_id, "exitCode": 0}) await task @@ -636,9 +647,7 @@ async def test_exec_error_frame_rejects(self): exec_id = task.exec_id await asyncio.sleep(0) - sandbox.session.handle_exec_frame( - {"type": "error", "execId": exec_id, "message": "command not found"} - ) + sandbox.session.handle_exec_frame({"type": "error", "execId": exec_id, "message": "command not found"}) with pytest.raises(SandboxClientError, match="command not found"): await task @@ -652,9 +661,7 @@ def test_handle_exec_frame_ignores_missing_pending_exec(self): sandbox = _make_sandbox() _inject_ws(sandbox) - sandbox.session.handle_exec_frame( - {"type": "exec.output", "execId": "exec-missing", "data": "ignored"} - ) + sandbox.session.handle_exec_frame({"type": "exec.output", "execId": "exec-missing", "data": "ignored"}) # --------------------------------------------------------------------------- @@ -673,6 +680,23 @@ async def test_read_file_base64_content(self): assert result == "console.log('hi')" + @pytest.mark.asyncio + async def test_write_file_encodes_content_and_delegates(self): + sandbox = _make_sandbox() + _inject_ws(sandbox) + write_result = WriteResult(path="/app/hello.js", ok=True, size=17) + + with patch.object(sandbox, "file_op", new=AsyncMock(return_value=write_result)) as file_op: + result = await sandbox.write_file("/app/hello.js", "console.log('hi')") + + assert result is write_result + file_op.assert_awaited_once_with( + "file.write", + path="/app/hello.js", + content=base64.b64encode(b"console.log('hi')").decode(), + encoding="base64", + ) + @pytest.mark.asyncio async def test_read_file_via_frame_handler(self): sandbox = _make_sandbox() @@ -742,9 +766,7 @@ async def test_list_files(self): {"name": "hello.js", "type": "file", "size": 42}, {"name": "src", "type": "directory"}, ] - sandbox.session.handle_file_frame( - {"type": "file.entries", "execId": exec_id, "entries": entries} - ) + sandbox.session.handle_file_frame({"type": "file.entries", "execId": exec_id, "entries": entries}) result = await future assert len(result) == 2 @@ -770,9 +792,7 @@ def test_handle_file_frame_ignores_missing_pending_operation(self): sandbox = _make_sandbox() _inject_ws(sandbox) - sandbox.session.handle_file_frame( - {"type": "file.content", "execId": "file-missing", "content": "ignored"} - ) + sandbox.session.handle_file_frame({"type": "file.content", "execId": "file-missing", "content": "ignored"}) @pytest.mark.asyncio async def test_file_error_frame_rejects_pending_operation(self): @@ -781,9 +801,7 @@ async def test_file_error_frame_rejects_pending_operation(self): future = asyncio.get_running_loop().create_future() sandbox.session.pending_file_ops["file-err"] = PendingFileOp(future=future) - sandbox.session.handle_file_frame( - {"type": "error", "execId": "file-err", "message": "read failed"} - ) + sandbox.session.handle_file_frame({"type": "error", "execId": "file-err", "message": "read failed"}) with pytest.raises(SandboxClientError, match="read failed"): await future @@ -1165,8 +1183,10 @@ async def _mock_req(method, url, *, api_key, body=None, **kw): "maxLifetime": 3600, } - with patch("aio_lib_sandbox.sandbox.api_request", new=_mock_req), \ - patch.object(Sandbox, "connect", new=AsyncMock()): + with ( + patch("aio_lib_sandbox.sandbox.api_request", new=_mock_req), + patch.object(Sandbox, "connect", new=AsyncMock()), + ): await Sandbox.create( name="sb", api_host="https://runtime.example.net", @@ -1191,8 +1211,10 @@ async def _mock_req(method, url, *, api_key, body=None, **kw): "maxLifetime": 3600, } - with patch("aio_lib_sandbox.sandbox.api_request", new=_mock_req), \ - patch.object(Sandbox, "connect", new=AsyncMock()): + with ( + patch("aio_lib_sandbox.sandbox.api_request", new=_mock_req), + patch.object(Sandbox, "connect", new=AsyncMock()), + ): await Sandbox.create( name="sb", api_host="https://runtime.example.net", @@ -1207,6 +1229,7 @@ async def _mock_req(method, url, *, api_key, body=None, **kw): # Detached exec # --------------------------------------------------------------------------- + class TestDetachedExec: @pytest.mark.asyncio async def test_exec_detached_resolves_with_handle_on_exec_detached(self): @@ -1240,16 +1263,12 @@ async def test_exec_detached_wait_resolves_on_exec_exit(self): exec_id = task.exec_id await asyncio.sleep(0) - sandbox.session.handle_exec_frame( - {"type": "exec.detached", "execId": exec_id, "pid": 1234, "startedAt": 1000} - ) + sandbox.session.handle_exec_frame({"type": "exec.detached", "execId": exec_id, "pid": 1234, "startedAt": 1000}) handle = await task wait_coro = handle.wait() - sandbox.session.handle_exec_frame( - {"type": "exec.exit", "execId": exec_id, "exitCode": 0} - ) + sandbox.session.handle_exec_frame({"type": "exec.exit", "execId": exec_id, "exitCode": 0}) result = await wait_coro assert result["exit_code"] == 0 @@ -1265,9 +1284,7 @@ async def test_exec_detached_output_frames_delivered_after_detached_ack(self): exec_id = task.exec_id await asyncio.sleep(0) - sandbox.session.handle_exec_frame( - {"type": "exec.detached", "execId": exec_id, "pid": 9000, "startedAt": 1} - ) + sandbox.session.handle_exec_frame({"type": "exec.detached", "execId": exec_id, "pid": 9000, "startedAt": 1}) await task sandbox.session.handle_exec_frame( @@ -1285,15 +1302,11 @@ async def test_exec_detached_error_after_ack_rejects_wait(self): exec_id = task.exec_id await asyncio.sleep(0) - sandbox.session.handle_exec_frame( - {"type": "exec.detached", "execId": exec_id, "pid": 1, "startedAt": 1} - ) + sandbox.session.handle_exec_frame({"type": "exec.detached", "execId": exec_id, "pid": 1, "startedAt": 1}) handle = await task wait_coro = handle.wait() - sandbox.session.handle_exec_frame( - {"type": "error", "execId": exec_id, "message": "process crashed"} - ) + sandbox.session.handle_exec_frame({"type": "error", "execId": exec_id, "message": "process crashed"}) with pytest.raises(SandboxClientError, match="process crashed"): await wait_coro @@ -1344,9 +1357,7 @@ async def test_detached_ack_cancels_timeout_handle(self): wait_future=wait_future, ) - sandbox.session.handle_exec_frame( - {"type": "exec.detached", "execId": "exec-1", "pid": 1234, "startedAt": 100} - ) + sandbox.session.handle_exec_frame({"type": "exec.detached", "execId": "exec-1", "pid": 1234, "startedAt": 100}) timeout_handle.cancel.assert_called_once() assert sandbox.session.pending_execs["exec-1"].timeout_handle is None @@ -1362,9 +1373,7 @@ async def test_exec_exit_cancels_timeout_handle(self): timeout_handle=timeout_handle, ) - sandbox.session.handle_exec_frame( - {"type": "exec.exit", "execId": "exec-1", "exitCode": 0} - ) + sandbox.session.handle_exec_frame({"type": "exec.exit", "execId": "exec-1", "exitCode": 0}) timeout_handle.cancel.assert_called_once() assert (await future).exit_code == 0 @@ -1374,6 +1383,7 @@ async def test_exec_exit_cancels_timeout_handle(self): # get_command # --------------------------------------------------------------------------- + class TestGetCommand: @pytest.mark.asyncio async def test_get_command_resolves_with_handle_on_exec_info(self): @@ -1387,14 +1397,16 @@ async def test_get_command_resolves_with_handle_on_exec_info(self): await asyncio.sleep(0) - sandbox.session.handle_get_frame({ - "type": "exec.info", - "execId": "exec-d1e2f3a4", - "command": "npm run dev", - "pid": 5678, - "startedAt": 1711036812, - "detached": True, - }) + sandbox.session.handle_get_frame( + { + "type": "exec.info", + "execId": "exec-d1e2f3a4", + "command": "npm run dev", + "pid": 5678, + "startedAt": 1711036812, + "detached": True, + } + ) handle = await task assert isinstance(handle, DetachedCommandHandle) @@ -1414,21 +1426,21 @@ async def test_get_command_wait_resolves_on_exec_exit(self): get_task = loop.create_task(coro) await asyncio.sleep(0) - sandbox.session.handle_get_frame({ - "type": "exec.info", - "execId": "exec-reattach", - "command": "sleep 60", - "pid": 1111, - "startedAt": 100, - "detached": True, - }) + sandbox.session.handle_get_frame( + { + "type": "exec.info", + "execId": "exec-reattach", + "command": "sleep 60", + "pid": 1111, + "startedAt": 100, + "detached": True, + } + ) handle = await get_task wait_coro = handle.wait() - sandbox.session.handle_exec_frame( - {"type": "exec.exit", "execId": "exec-reattach", "exitCode": 143} - ) + sandbox.session.handle_exec_frame({"type": "exec.exit", "execId": "exec-reattach", "exitCode": 143}) result = await wait_coro assert result["exit_code"] == 143 @@ -1444,12 +1456,14 @@ async def test_get_command_raises_command_not_found_on_error(self): get_task = loop.create_task(coro) await asyncio.sleep(0) - sandbox.session.handle_get_frame({ - "type": "error", - "execId": "exec-gone", - "code": "NOT_FOUND", - "message": "no running process for execId", - }) + sandbox.session.handle_get_frame( + { + "type": "error", + "execId": "exec-gone", + "code": "NOT_FOUND", + "message": "no running process for execId", + } + ) with pytest.raises(SandboxCommandNotFoundError): await get_task @@ -1480,14 +1494,16 @@ async def test_get_command_reuses_existing_exec_and_merges_output_callbacks(self ) ) await asyncio.sleep(0) - sandbox.session.handle_get_frame({ - "type": "exec.info", - "execId": task.exec_id, - "command": "npm run dev", - "pid": 1234, - "startedAt": 100, - "detached": True, - }) + sandbox.session.handle_get_frame( + { + "type": "exec.info", + "execId": task.exec_id, + "command": "npm run dev", + "pid": 1234, + "startedAt": 100, + "detached": True, + } + ) reattached_handle = await get_task assert reattached_handle._wait_future is original_handle._wait_future @@ -1516,14 +1532,14 @@ def test_handle_get_frame_ignores_missing_pending_operation(self): sandbox = _make_sandbox() _inject_ws(sandbox) - sandbox.session.handle_get_frame( - {"type": "exec.info", "execId": "exec-missing"} - ) + sandbox.session.handle_get_frame({"type": "exec.info", "execId": "exec-missing"}) + # --------------------------------------------------------------------------- # _parse_preview_urls # --------------------------------------------------------------------------- + class TestParsePreviewUrls: def test_returns_empty_for_non_dict(self): assert _parse_preview_urls(None) == {} @@ -1545,8 +1561,11 @@ def test_skips_non_integer_keys(self): assert result == {3000: "https://sb-3000.example.net"} def test_skips_out_of_range_ports(self): - raw = {"0": "https://zero.example.net", "65536": "https://toobig.example.net", - "3000": "https://sb-3000.example.net"} + raw = { + "0": "https://zero.example.net", + "65536": "https://toobig.example.net", + "3000": "https://sb-3000.example.net", + } result = _parse_preview_urls(raw) assert result == {3000: "https://sb-3000.example.net"}