Compare commits
12 Commits
feat/modul
...
e057ec2407
| Author | SHA1 | Date | |
|---|---|---|---|
| e057ec2407 | |||
| 6112c71abc | |||
| a42f0b2e71 | |||
| d9c028564e | |||
| 06b4a8cbcb | |||
| 04e5dc652f | |||
| 4a7db8e204 | |||
| a76be96682 | |||
| be573d07a3 | |||
| dc66ac24d5 | |||
| b5e2b12ef1 | |||
| 213af4aee7 |
6
.gitignore
vendored
6
.gitignore
vendored
@ -175,3 +175,9 @@ cython_debug/
|
||||
.pypirc
|
||||
|
||||
CODE_EXECUTION.md
|
||||
|
||||
.opencode/
|
||||
# AI
|
||||
.code-review-graph/
|
||||
.claude
|
||||
.mcp.json
|
||||
|
||||
25
.pre-commit-config.yaml
Normal file
25
.pre-commit-config.yaml
Normal file
@ -0,0 +1,25 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.10
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.20.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
- pydantic>=2.12.5
|
||||
- httpx>=0.28.1
|
||||
- httpx-ws>=0.9.0
|
||||
- email-validator>=2.3.0
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: unit-tests
|
||||
name: unit tests
|
||||
entry: uv run pytest -m "not integration" -x -q
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
@ -1,5 +1,5 @@
|
||||
when:
|
||||
event: push
|
||||
event: pull_request
|
||||
branch:
|
||||
- main
|
||||
- dev
|
||||
|
||||
56
AGENTS.md
Normal file
56
AGENTS.md
Normal file
@ -0,0 +1,56 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project
|
||||
|
||||
Wrenn Python SDK — a client library for the Wrenn microVM platform. e2b drop-in replacement.
|
||||
Package name: `wrenn`. Python 3.13+, managed with [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
uv sync # install deps
|
||||
make lint # ruff check + format check (no auto-fix)
|
||||
make test # unit tests only (tests/test_client.py)
|
||||
make test-integration # all tests including integration (needs live server)
|
||||
make generate # regenerate models from OpenAPI spec (fetches from remote)
|
||||
make check # lint + unit test
|
||||
```
|
||||
|
||||
- `make test` only runs `tests/test_client.py`, not all unit tests. To run a specific test file: `uv run pytest tests/test_capsule_features.py -v`
|
||||
- No typecheck step in Makefile or CI. `mypy` is a dev dependency but not wired up — do not assume it runs.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/wrenn/` — the library package
|
||||
- `capsule.py` / `async_capsule.py` — high-level `Capsule` / `AsyncCapsule` (main user-facing classes)
|
||||
- `client.py` — low-level `WrennClient` / `AsyncWrennClient`
|
||||
- `commands.py` — command execution and streaming
|
||||
- `files.py` — filesystem operations
|
||||
- `pty.py` — interactive terminal (PTY) over WebSocket
|
||||
- `exceptions.py` — typed error hierarchy (`WrennError` base)
|
||||
- `models/_generated.py` — **auto-generated** from OpenAPI spec via `datamodel-codegen` (never edit directly; run `make generate`)
|
||||
- `sandbox.py` — deprecated `Sandbox` alias for `Capsule`
|
||||
- `code_interpreter/` — specialized capsule for stateful Jupyter kernel execution
|
||||
- `tests/` — unit tests use `respx` to mock `httpx`; integration tests are in `tests/integration/`
|
||||
- `api/openapi.yaml` — downloaded OpenAPI spec used for code generation
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- Generated code lives in `src/wrenn/models/_generated.py`. Never edit it. Run `make generate` to update.
|
||||
- `Sandbox` is a deprecated alias for `Capsule`. New code should use `Capsule` / `AsyncCapsule`.
|
||||
- Dual sync/async API: every major class has an `Async` counterpart.
|
||||
- Uses `httpx` for HTTP, `httpx-ws` for WebSockets, `pydantic` for models.
|
||||
- `__init__.py` uses `__getattr__` for lazy deprecated aliases (`Sandbox`, `WrennHostHasSandboxesError`).
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests mock HTTP via `respx` (httpx mocking library).
|
||||
- Integration tests require env vars: `WRENN_API_KEY` (or `WRENN_TOKEN`), optionally `WRENN_BASE_URL`.
|
||||
- Integration test fixtures in `tests/integration/conftest.py` create real capsules and clean them up.
|
||||
- `pytest` marker: `@pytest.mark.integration` for tests needing a live server.
|
||||
|
||||
## CI
|
||||
|
||||
Woodpecker CI (`.woodpecker/check.yml`) runs on push to `main` and `dev`:
|
||||
1. `make lint`
|
||||
2. `make test` (unit tests only — integration tests are not in CI)
|
||||
39
CLAUDE.md
39
CLAUDE.md
@ -130,3 +130,42 @@ All values are CSS custom properties in `frontend/src/app.css`.
|
||||
4. **Legible at speed.** Users scan dashboards in seconds. Strong typographic contrast (serif h1, mono IDs, sans body), consistent patterns, and predictable placement let users orientate instantly without reading everything.
|
||||
|
||||
5. **Craft signals trust.** For infrastructure that runs production code, the quality of the UI is a proxy for the quality of the product. Pixel-level decisions matter. Polish is not decoration — it's a trust signal.
|
||||
|
||||
<!-- code-review-graph MCP tools -->
|
||||
## MCP Tools: code-review-graph
|
||||
|
||||
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||
you structural context (callers, dependents, test coverage) that file
|
||||
scanning cannot.
|
||||
|
||||
### When to use graph tools FIRST
|
||||
|
||||
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||
|
||||
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
|
||||
### Key Tools
|
||||
|
||||
| Tool | Use when |
|
||||
|------|----------|
|
||||
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||
| `get_impact_radius` | Understanding blast radius of a change |
|
||||
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||
| `refactor_tool` | Planning renames, finding dead code |
|
||||
|
||||
### Workflow
|
||||
|
||||
1. The graph auto-updates on file changes (via hooks).
|
||||
2. Use `detect_changes` for code review.
|
||||
3. Use `get_affected_flows` to understand impact.
|
||||
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||
|
||||
@ -2,7 +2,7 @@ openapi: "3.1.0"
|
||||
info:
|
||||
title: Wrenn API
|
||||
description: MicroVM-based code execution platform API.
|
||||
version: "0.1.3"
|
||||
version: "0.1.4"
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8080
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "wrenn"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
description = "Python SDK for Wrenn"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@ -36,6 +36,7 @@ build-backend = "hatchling.build"
|
||||
dev = [
|
||||
"datamodel-code-generator[ruff]>=0.56.0",
|
||||
"mypy>=1.20.0",
|
||||
"pre-commit>=4.6.0",
|
||||
"pydoc-markdown>=4.8.2",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
|
||||
@ -1,33 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
DEFAULT_BASE_URL = "https://app.wrenn.dev/api"
|
||||
ENV_API_KEY = "WRENN_API_KEY"
|
||||
ENV_BASE_URL = "WRENN_BASE_URL"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConnectionConfig:
|
||||
"""Resolved credentials and base URL for Wrenn API calls."""
|
||||
|
||||
api_key: str
|
||||
base_url: str
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
cls,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> ConnectionConfig:
|
||||
resolved_key = api_key or os.environ.get(ENV_API_KEY)
|
||||
if not resolved_key:
|
||||
raise ValueError(
|
||||
f"No API key provided. Pass api_key= or set the {ENV_API_KEY} environment variable."
|
||||
)
|
||||
resolved_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL)
|
||||
return cls(api_key=resolved_key, base_url=resolved_url)
|
||||
|
||||
def auth_headers(self) -> dict[str, str]:
|
||||
return {"X-API-Key": self.api_key}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import builtins
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
@ -102,6 +104,7 @@ class AsyncCapsule:
|
||||
memory_mb=memory_mb,
|
||||
timeout_sec=timeout,
|
||||
)
|
||||
assert info.id is not None
|
||||
capsule = cls(
|
||||
_capsule_id=info.id,
|
||||
_client=client,
|
||||
@ -240,8 +243,10 @@ class AsyncCapsule:
|
||||
if info.status == Status.running:
|
||||
self._info = info
|
||||
return
|
||||
if info.status in (Status.error, Status.stopped, Status.paused):
|
||||
if info.status in (Status.error, Status.stopped):
|
||||
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
||||
if info.status == Status.paused:
|
||||
info = await self._client.capsules.resume(self._id)
|
||||
await asyncio.sleep(interval)
|
||||
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
||||
|
||||
@ -284,7 +289,7 @@ class AsyncCapsule:
|
||||
async def pty(
|
||||
self,
|
||||
cmd: str = "/bin/bash",
|
||||
args: list[str] | None = None,
|
||||
args: builtins.list[str] | None = None,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
envs: dict[str, str] | None = None,
|
||||
@ -316,7 +321,7 @@ class AsyncCapsule:
|
||||
"""
|
||||
async with httpx_ws.aconnect_ws(
|
||||
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
||||
) as ws:
|
||||
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||
session = AsyncPtySession(ws, self._id)
|
||||
await session._send_start(
|
||||
cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd
|
||||
@ -335,7 +340,7 @@ class AsyncCapsule:
|
||||
"""
|
||||
async with httpx_ws.aconnect_ws(
|
||||
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
||||
) as ws:
|
||||
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||
session = AsyncPtySession(ws, self._id)
|
||||
await session._send_connect(tag)
|
||||
yield session
|
||||
@ -387,8 +392,8 @@ class AsyncCapsule:
|
||||
) -> None:
|
||||
try:
|
||||
await self._instance_destroy()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.warning("Failed to destroy capsule %s: %s", self._id, exc)
|
||||
try:
|
||||
await self._client.aclose()
|
||||
except Exception:
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import builtins
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
@ -94,21 +96,28 @@ class Capsule:
|
||||
``WRENN_BASE_URL`` or the default production endpoint.
|
||||
"""
|
||||
if _capsule_id is not None:
|
||||
# Internal construction path (from create/connect classmethods)
|
||||
assert _client is not None
|
||||
self._id = _capsule_id
|
||||
self._id: str = _capsule_id
|
||||
self._client = _client
|
||||
self._info = _info
|
||||
if self._id is None:
|
||||
self._client.close()
|
||||
raise RuntimeError("API returned a capsule without an ID")
|
||||
else:
|
||||
# Public construction: create a capsule immediately
|
||||
self._client = WrennClient(api_key=api_key, base_url=base_url)
|
||||
self._info = self._client.capsules.create(
|
||||
template=template,
|
||||
vcpus=vcpus,
|
||||
memory_mb=memory_mb,
|
||||
timeout_sec=timeout,
|
||||
)
|
||||
self._id = self._info.id
|
||||
try:
|
||||
self._info = self._client.capsules.create(
|
||||
template=template,
|
||||
vcpus=vcpus,
|
||||
memory_mb=memory_mb,
|
||||
timeout_sec=timeout,
|
||||
)
|
||||
self._id = self._info.id
|
||||
if self._id is None:
|
||||
raise RuntimeError("API returned a capsule without an ID")
|
||||
except Exception:
|
||||
self._client.close()
|
||||
raise
|
||||
|
||||
self.commands = Commands(self._id, self._client.http)
|
||||
self.files = Files(self._id, self._client.http)
|
||||
@ -316,8 +325,10 @@ class Capsule:
|
||||
if info.status == Status.running:
|
||||
self._info = info
|
||||
return
|
||||
if info.status in (Status.error, Status.stopped, Status.paused):
|
||||
if info.status in (Status.error, Status.stopped):
|
||||
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
||||
if info.status == Status.paused:
|
||||
info = self._client.capsules.resume(self._id)
|
||||
time.sleep(interval)
|
||||
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
||||
|
||||
@ -360,7 +371,7 @@ class Capsule:
|
||||
def pty(
|
||||
self,
|
||||
cmd: str = "/bin/bash",
|
||||
args: list[str] | None = None,
|
||||
args: builtins.list[str] | None = None,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
envs: dict[str, str] | None = None,
|
||||
@ -391,7 +402,7 @@ class Capsule:
|
||||
"""
|
||||
with httpx_ws.connect_ws(
|
||||
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
||||
) as ws:
|
||||
) as ws: # type: httpx_ws.WebSocketSession
|
||||
session = PtySession(ws, self._id)
|
||||
session._send_start(
|
||||
cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd
|
||||
@ -410,7 +421,7 @@ class Capsule:
|
||||
"""
|
||||
with httpx_ws.connect_ws(
|
||||
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
||||
) as ws:
|
||||
) as ws: # type: httpx_ws.WebSocketSession
|
||||
session = PtySession(ws, self._id)
|
||||
session._send_connect(tag)
|
||||
yield session
|
||||
@ -462,8 +473,8 @@ class Capsule:
|
||||
) -> None:
|
||||
try:
|
||||
self._instance_destroy()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logging.warning("Failed to destroy capsule %s: %s", self._id, exc)
|
||||
try:
|
||||
self._client.close()
|
||||
except Exception:
|
||||
|
||||
@ -6,6 +6,7 @@ import httpx
|
||||
|
||||
from wrenn._config import DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL
|
||||
from wrenn.exceptions import handle_response
|
||||
|
||||
from wrenn.models import (
|
||||
Template,
|
||||
)
|
||||
@ -13,6 +14,8 @@ from wrenn.models import (
|
||||
Capsule as CapsuleModel,
|
||||
)
|
||||
|
||||
_LONG_TIMEOUT = httpx.Timeout(60.0)
|
||||
|
||||
|
||||
def _resolve_api_key(api_key: str | None) -> str:
|
||||
resolved = api_key or os.environ.get(ENV_API_KEY)
|
||||
@ -108,7 +111,7 @@ class CapsulesResource:
|
||||
Raises:
|
||||
WrennNotFoundError: If no capsule with the given ID exists.
|
||||
"""
|
||||
resp = self._http.post(f"/v1/capsules/{id}/pause")
|
||||
resp = self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT)
|
||||
return CapsuleModel.model_validate(handle_response(resp))
|
||||
|
||||
def resume(self, id: str) -> CapsuleModel:
|
||||
@ -224,7 +227,7 @@ class AsyncCapsulesResource:
|
||||
Raises:
|
||||
WrennNotFoundError: If no capsule with the given ID exists.
|
||||
"""
|
||||
resp = await self._http.post(f"/v1/capsules/{id}/pause")
|
||||
resp = await self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT)
|
||||
return CapsuleModel.model_validate(handle_response(resp))
|
||||
|
||||
async def resume(self, id: str) -> CapsuleModel:
|
||||
@ -285,7 +288,9 @@ class SnapshotsResource:
|
||||
params: dict = {}
|
||||
if overwrite:
|
||||
params["overwrite"] = "true"
|
||||
resp = self._http.post("/v1/snapshots", json=payload, params=params)
|
||||
resp = self._http.post(
|
||||
"/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT
|
||||
)
|
||||
return Template.model_validate(handle_response(resp))
|
||||
|
||||
def list(self, type: str | None = None) -> list[Template]:
|
||||
@ -347,7 +352,9 @@ class AsyncSnapshotsResource:
|
||||
params: dict = {}
|
||||
if overwrite:
|
||||
params["overwrite"] = "true"
|
||||
resp = await self._http.post("/v1/snapshots", json=payload, params=params)
|
||||
resp = await self._http.post(
|
||||
"/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT
|
||||
)
|
||||
return Template.model_validate(handle_response(resp))
|
||||
|
||||
async def list(self, type: str | None = None) -> list[Template]:
|
||||
|
||||
@ -40,6 +40,28 @@ class AsyncCapsule(BaseAsyncCapsule):
|
||||
self._kernel_id = None
|
||||
self._proxy_client = None
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._proxy_client is not None:
|
||||
try:
|
||||
await self._proxy_client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
self._proxy_client = None
|
||||
|
||||
def __del__(self) -> None:
|
||||
if self._proxy_client is not None:
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
loop.create_task(self._proxy_client.aclose())
|
||||
else:
|
||||
loop.run_until_complete(self._proxy_client.aclose())
|
||||
except Exception:
|
||||
pass
|
||||
self._proxy_client = None
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
@ -126,8 +148,10 @@ class AsyncCapsule(BaseAsyncCapsule):
|
||||
request=resp.request,
|
||||
response=resp,
|
||||
)
|
||||
except httpx.HTTPStatusError:
|
||||
raise
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code < 500:
|
||||
raise
|
||||
last_exc = exc
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
await asyncio.sleep(0.5)
|
||||
@ -164,8 +188,6 @@ class AsyncCapsule(BaseAsyncCapsule):
|
||||
},
|
||||
"buffers": [],
|
||||
"channel": "shell",
|
||||
"msg_id": msg_id,
|
||||
"msg_type": "execute_request",
|
||||
}
|
||||
|
||||
async def run_code(
|
||||
@ -201,13 +223,13 @@ class AsyncCapsule(BaseAsyncCapsule):
|
||||
ws_url = self._jupyter_ws_url(kernel_id)
|
||||
|
||||
msg = self._jupyter_execute_request(code)
|
||||
msg_id = msg["msg_id"]
|
||||
msg_id = msg["header"]["msg_id"]
|
||||
|
||||
execution = Execution()
|
||||
deadline = time.monotonic() + timeout
|
||||
headers = {"X-API-Key": self._client._api_key}
|
||||
|
||||
async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws:
|
||||
async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||
await ws.send_text(json.dumps(msg))
|
||||
while time.monotonic() < deadline:
|
||||
time_left = deadline - time.monotonic()
|
||||
@ -215,7 +237,7 @@ class AsyncCapsule(BaseAsyncCapsule):
|
||||
break
|
||||
try:
|
||||
data = await asyncio.wait_for(ws.receive_json(), timeout=time_left)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
except Exception:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
|
||||
@ -70,6 +70,17 @@ class Capsule(BaseCapsule):
|
||||
self._kernel_id = None
|
||||
self._proxy_client = None
|
||||
|
||||
def close(self) -> None:
|
||||
if self._proxy_client is not None:
|
||||
try:
|
||||
self._proxy_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._proxy_client = None
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.close()
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
@ -150,8 +161,10 @@ class Capsule(BaseCapsule):
|
||||
request=resp.request,
|
||||
response=resp,
|
||||
)
|
||||
except httpx.HTTPStatusError:
|
||||
raise
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code < 500:
|
||||
raise
|
||||
last_exc = exc
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
time.sleep(0.5)
|
||||
@ -188,8 +201,6 @@ class Capsule(BaseCapsule):
|
||||
},
|
||||
"buffers": [],
|
||||
"channel": "shell",
|
||||
"msg_id": msg_id,
|
||||
"msg_type": "execute_request",
|
||||
}
|
||||
|
||||
def run_code(
|
||||
@ -227,13 +238,13 @@ class Capsule(BaseCapsule):
|
||||
ws_url = self._jupyter_ws_url(kernel_id)
|
||||
|
||||
msg = self._jupyter_execute_request(code)
|
||||
msg_id = msg["msg_id"]
|
||||
msg_id = msg["header"]["msg_id"]
|
||||
|
||||
execution = Execution()
|
||||
deadline = time.monotonic() + timeout
|
||||
headers = {"X-API-Key": self._client._api_key}
|
||||
|
||||
with httpx_ws.connect_ws(ws_url, headers=headers) as ws:
|
||||
with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.WebSocketSession
|
||||
ws.send_text(json.dumps(msg))
|
||||
while time.monotonic() < deadline:
|
||||
time_left = deadline - time.monotonic()
|
||||
@ -241,7 +252,7 @@ class Capsule(BaseCapsule):
|
||||
break
|
||||
try:
|
||||
data = ws.receive_json(timeout=time_left)
|
||||
except (TimeoutError, Exception):
|
||||
except Exception:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import builtins
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import overload, Literal
|
||||
from typing import Literal, overload
|
||||
|
||||
import httpx
|
||||
import httpx_ws
|
||||
@ -197,8 +198,17 @@ class Commands:
|
||||
if tag is not None:
|
||||
payload["tag"] = tag
|
||||
|
||||
resp = self._http.post(f"/v1/capsules/{self._capsule_id}/exec", json=payload)
|
||||
http_timeout: httpx.Timeout | None = None
|
||||
if not background and timeout is not None:
|
||||
http_timeout = httpx.Timeout(timeout + 10, connect=5.0)
|
||||
|
||||
resp = self._http.post(
|
||||
f"/v1/capsules/{self._capsule_id}/exec",
|
||||
json=payload,
|
||||
timeout=http_timeout,
|
||||
)
|
||||
data = handle_response(resp)
|
||||
assert isinstance(data, dict)
|
||||
|
||||
if background:
|
||||
return CommandHandle(
|
||||
@ -217,6 +227,7 @@ class Commands:
|
||||
"""
|
||||
resp = self._http.get(f"/v1/capsules/{self._capsule_id}/processes")
|
||||
data = handle_response(resp)
|
||||
assert isinstance(data, dict)
|
||||
return [
|
||||
ProcessInfo(
|
||||
pid=p.get("pid", 0),
|
||||
@ -252,7 +263,7 @@ class Commands:
|
||||
with httpx_ws.connect_ws(
|
||||
f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream",
|
||||
self._http,
|
||||
) as ws:
|
||||
) as ws: # type: httpx_ws.WebSocketSession
|
||||
while True:
|
||||
try:
|
||||
raw = ws.receive_json()
|
||||
@ -263,7 +274,9 @@ class Commands:
|
||||
except httpx_ws.WebSocketDisconnect:
|
||||
break
|
||||
|
||||
def stream(self, cmd: str, args: list[str] | None = None) -> Iterator[StreamEvent]:
|
||||
def stream(
|
||||
self, cmd: str, args: builtins.list[str] | None = None
|
||||
) -> Iterator[StreamEvent]:
|
||||
"""Execute a command via WebSocket, streaming output as events.
|
||||
|
||||
Args:
|
||||
@ -280,7 +293,7 @@ class Commands:
|
||||
with httpx_ws.connect_ws(
|
||||
f"/v1/capsules/{self._capsule_id}/exec/stream",
|
||||
self._http,
|
||||
) as ws:
|
||||
) as ws: # type: httpx_ws.WebSocketSession
|
||||
if args:
|
||||
start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
|
||||
else:
|
||||
@ -374,10 +387,17 @@ class AsyncCommands:
|
||||
if tag is not None:
|
||||
payload["tag"] = tag
|
||||
|
||||
http_timeout: httpx.Timeout | None = None
|
||||
if not background and timeout is not None:
|
||||
http_timeout = httpx.Timeout(timeout + 10, connect=5.0)
|
||||
|
||||
resp = await self._http.post(
|
||||
f"/v1/capsules/{self._capsule_id}/exec", json=payload
|
||||
f"/v1/capsules/{self._capsule_id}/exec",
|
||||
json=payload,
|
||||
timeout=http_timeout,
|
||||
)
|
||||
data = handle_response(resp)
|
||||
assert isinstance(data, dict)
|
||||
|
||||
if background:
|
||||
return CommandHandle(
|
||||
@ -396,6 +416,7 @@ class AsyncCommands:
|
||||
"""
|
||||
resp = await self._http.get(f"/v1/capsules/{self._capsule_id}/processes")
|
||||
data = handle_response(resp)
|
||||
assert isinstance(data, dict)
|
||||
return [
|
||||
ProcessInfo(
|
||||
pid=p.get("pid", 0),
|
||||
@ -433,7 +454,7 @@ class AsyncCommands:
|
||||
async with httpx_ws.aconnect_ws(
|
||||
f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream",
|
||||
self._http,
|
||||
) as ws:
|
||||
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||
try:
|
||||
while True:
|
||||
raw = await ws.receive_json()
|
||||
@ -445,7 +466,7 @@ class AsyncCommands:
|
||||
pass
|
||||
|
||||
async def stream(
|
||||
self, cmd: str, args: list[str] | None = None
|
||||
self, cmd: str, args: builtins.list[str] | None = None
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""Execute a command via WebSocket, streaming output as events.
|
||||
|
||||
@ -463,7 +484,7 @@ class AsyncCommands:
|
||||
async with httpx_ws.aconnect_ws(
|
||||
f"/v1/capsules/{self._capsule_id}/exec/stream",
|
||||
self._http,
|
||||
) as ws:
|
||||
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||
if args:
|
||||
start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
|
||||
else:
|
||||
|
||||
@ -110,34 +110,43 @@ _ERROR_MAP: dict[str, type[WrennError]] = {
|
||||
}
|
||||
|
||||
|
||||
def handle_response(resp: httpx.Response) -> dict | list:
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
resp.raise_for_status()
|
||||
raise
|
||||
def _raise_for_status(resp: httpx.Response) -> None:
|
||||
if resp.status_code < 400:
|
||||
return
|
||||
|
||||
err = body.get("error", {})
|
||||
code = err.get("code", "internal_error")
|
||||
message = err.get("message", resp.text)
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
raise WrennInternalError(
|
||||
code="internal_error",
|
||||
message=resp.text or f"HTTP {resp.status_code}",
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
|
||||
exc_cls = _ERROR_MAP.get(code, WrennError)
|
||||
err = body.get("error", {})
|
||||
code = err.get("code", "internal_error")
|
||||
message = err.get("message", resp.text)
|
||||
|
||||
if exc_cls is WrennHostHasCapsulesError:
|
||||
raise WrennHostHasCapsulesError(
|
||||
code=code,
|
||||
message=message,
|
||||
status_code=resp.status_code,
|
||||
capsule_ids=body.get("sandbox_ids", []),
|
||||
)
|
||||
exc_cls = _ERROR_MAP.get(code, WrennError)
|
||||
|
||||
raise exc_cls(
|
||||
if exc_cls is WrennHostHasCapsulesError:
|
||||
raise WrennHostHasCapsulesError(
|
||||
code=code,
|
||||
message=message,
|
||||
status_code=resp.status_code,
|
||||
capsule_ids=body.get("capsule_ids") or body.get("sandbox_ids", []),
|
||||
)
|
||||
|
||||
raise exc_cls(
|
||||
code=code,
|
||||
message=message,
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
|
||||
|
||||
def handle_response(resp: httpx.Response) -> dict | list:
|
||||
_raise_for_status(resp)
|
||||
|
||||
if resp.status_code == 204:
|
||||
return {}
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ from collections.abc import AsyncIterator, Iterator
|
||||
|
||||
import httpx
|
||||
|
||||
from wrenn.exceptions import WrennNotFoundError, handle_response
|
||||
from wrenn.exceptions import WrennNotFoundError, _raise_for_status, handle_response
|
||||
from wrenn.models import FileEntry, ListDirResponse, MakeDirResponse
|
||||
|
||||
|
||||
@ -46,7 +46,7 @@ class Files:
|
||||
f"/v1/capsules/{self._capsule_id}/files/read",
|
||||
json={"path": path},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
_raise_for_status(resp)
|
||||
return resp.content
|
||||
|
||||
def write(self, path: str, data: str | bytes) -> None:
|
||||
@ -65,7 +65,7 @@ class Files:
|
||||
files={"file": ("upload", data)},
|
||||
data={"path": path},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
_raise_for_status(resp)
|
||||
|
||||
def list(self, path: str, depth: int = 1) -> list[FileEntry]:
|
||||
"""List directory contents.
|
||||
@ -179,7 +179,7 @@ class Files:
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}"
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
_raise_for_status(resp)
|
||||
|
||||
def download_stream(self, path: str) -> Iterator[bytes]:
|
||||
"""Stream a large file out of the capsule.
|
||||
@ -243,7 +243,7 @@ class AsyncFiles:
|
||||
f"/v1/capsules/{self._capsule_id}/files/read",
|
||||
json={"path": path},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
_raise_for_status(resp)
|
||||
return resp.content
|
||||
|
||||
async def write(self, path: str, data: str | bytes) -> None:
|
||||
@ -262,7 +262,7 @@ class AsyncFiles:
|
||||
files={"file": ("upload", data)},
|
||||
data={"path": path},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
_raise_for_status(resp)
|
||||
|
||||
async def list(self, path: str, depth: int = 1) -> list[FileEntry]:
|
||||
"""List directory contents.
|
||||
@ -377,7 +377,7 @@ class AsyncFiles:
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}"
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
_raise_for_status(resp)
|
||||
|
||||
async def download_stream(self, path: str) -> AsyncIterator[bytes]:
|
||||
"""Stream a large file out of the capsule.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: openapi.yaml
|
||||
# timestamp: 2026-04-22T20:21:34+00:00
|
||||
# timestamp: 2026-05-04T20:57:00+00:00
|
||||
|
||||
from __future__ import annotations
|
||||
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
|
||||
|
||||
@ -153,7 +153,8 @@ class PtySession:
|
||||
if event.pid is not None:
|
||||
self._pid = event.pid
|
||||
if event.type == PtyEventType.exit:
|
||||
raise StopIteration
|
||||
self._done = True
|
||||
return event
|
||||
if event.type == PtyEventType.error and event.fatal:
|
||||
self._done = True
|
||||
return event
|
||||
@ -281,7 +282,8 @@ class AsyncPtySession:
|
||||
if event.pid is not None:
|
||||
self._pid = event.pid
|
||||
if event.type == PtyEventType.exit:
|
||||
raise StopAsyncIteration
|
||||
self._done = True
|
||||
return event
|
||||
if event.type == PtyEventType.error and event.fatal:
|
||||
self._done = True
|
||||
return event
|
||||
|
||||
@ -32,7 +32,7 @@ class TestCapsuleCreate:
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201, json={"id": "cl-1", "status": "pending", "template": "minimal"}
|
||||
)
|
||||
cap = Capsule(template="minimal", api_key="wrn_test1234567890abcdef12345678")
|
||||
cap = Capsule(template="minimal", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
assert cap.capsule_id == "cl-1"
|
||||
assert hasattr(cap, "commands")
|
||||
assert hasattr(cap, "files")
|
||||
@ -42,7 +42,7 @@ class TestCapsuleCreate:
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201, json={"id": "cl-2", "status": "pending"}
|
||||
)
|
||||
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678")
|
||||
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
assert cap.capsule_id == "cl-2"
|
||||
|
||||
@respx.mock
|
||||
@ -51,7 +51,7 @@ class TestCapsuleCreate:
|
||||
201, json={"id": "cl-1", "status": "pending"}
|
||||
)
|
||||
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
|
||||
with Capsule(api_key="wrn_test1234567890abcdef12345678") as cap:
|
||||
with Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as cap:
|
||||
assert cap.capsule_id == "cl-1"
|
||||
assert kill_route.called
|
||||
|
||||
@ -61,7 +61,7 @@ class TestCapsuleCreate:
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201, json={"id": "cl-3", "status": "pending"}
|
||||
)
|
||||
cap = Capsule()
|
||||
cap = Capsule(base_url=BASE)
|
||||
assert cap.capsule_id == "cl-3"
|
||||
|
||||
|
||||
@ -69,7 +69,7 @@ class TestCapsuleStaticMethods:
|
||||
@respx.mock
|
||||
def test_static_destroy(self):
|
||||
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
|
||||
Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678")
|
||||
Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
@ -77,7 +77,7 @@ class TestCapsuleStaticMethods:
|
||||
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
|
||||
200, json={"id": "cl-1", "status": "paused"}
|
||||
)
|
||||
info = Capsule._static_pause("cl-1", api_key="wrn_test1234567890abcdef12345678")
|
||||
info = Capsule._static_pause("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
assert info.status.value == "paused"
|
||||
|
||||
@respx.mock
|
||||
@ -85,7 +85,7 @@ class TestCapsuleStaticMethods:
|
||||
respx.get(f"{BASE}/v1/capsules").respond(
|
||||
200, json=[{"id": "cl-1", "status": "running"}]
|
||||
)
|
||||
items = Capsule.list(api_key="wrn_test1234567890abcdef12345678")
|
||||
items = Capsule.list(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
assert len(items) == 1
|
||||
assert items[0].id == "cl-1"
|
||||
|
||||
@ -95,7 +95,7 @@ class TestCapsuleStaticMethods:
|
||||
200, json={"id": "cl-1", "status": "running"}
|
||||
)
|
||||
info = Capsule._static_get_info(
|
||||
"cl-1", api_key="wrn_test1234567890abcdef12345678"
|
||||
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||
)
|
||||
assert info.id == "cl-1"
|
||||
|
||||
@ -106,7 +106,7 @@ class TestCapsuleConnect:
|
||||
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
||||
200, json={"id": "cl-1", "status": "running"}
|
||||
)
|
||||
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678")
|
||||
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
assert cap.capsule_id == "cl-1"
|
||||
|
||||
@respx.mock
|
||||
@ -117,7 +117,7 @@ class TestCapsuleConnect:
|
||||
respx.post(f"{BASE}/v1/capsules/cl-1/resume").respond(
|
||||
200, json={"id": "cl-1", "status": "running"}
|
||||
)
|
||||
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678")
|
||||
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
assert cap.capsule_id == "cl-1"
|
||||
|
||||
|
||||
|
||||
@ -23,13 +23,13 @@ BASE = "https://app.wrenn.dev/api"
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def async_client():
|
||||
return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678")
|
||||
return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
|
||||
|
||||
class TestCapsules:
|
||||
@ -221,7 +221,8 @@ class TestAuthModes:
|
||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
||||
assert c._http.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
|
||||
|
||||
def test_no_auth_raises(self):
|
||||
def test_no_auth_raises(self, monkeypatch):
|
||||
monkeypatch.delenv("WRENN_API_KEY", raising=False)
|
||||
with pytest.raises(ValueError, match="No API key"):
|
||||
WrennClient()
|
||||
|
||||
|
||||
@ -23,7 +23,7 @@ def _make_capsule(cap_id: str = "cl-abc") -> Capsule:
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201, json={"id": cap_id, "status": "running"}
|
||||
)
|
||||
return Capsule(api_key="wrn_test1234567890abcdef12345678")
|
||||
return Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
|
||||
|
||||
class TestFilesRead:
|
||||
@ -311,12 +311,14 @@ class TestPtySessionIteration:
|
||||
ws.receive_text.side_effect = messages
|
||||
session = PtySession(ws, "cl-abc")
|
||||
events = list(session)
|
||||
assert len(events) == 2
|
||||
assert len(events) == 3
|
||||
assert events[0].type == PtyEventType.started
|
||||
assert session.tag == "pty-abc12345"
|
||||
assert session.pid == 1
|
||||
assert events[1].type == PtyEventType.output
|
||||
assert events[1].data == b"hello"
|
||||
assert events[2].type == PtyEventType.exit
|
||||
assert events[2].exit_code == 0
|
||||
|
||||
def test_iter_stops_on_fatal_error(self):
|
||||
ws = MagicMock()
|
||||
@ -461,10 +463,11 @@ class TestAsyncPtySession:
|
||||
events = []
|
||||
async for event in session:
|
||||
events.append(event)
|
||||
assert len(events) == 2
|
||||
assert len(events) == 3
|
||||
assert events[0].type == PtyEventType.started
|
||||
assert session.tag == "pty-xyz"
|
||||
assert session.pid == 5
|
||||
assert events[2].type == PtyEventType.exit
|
||||
|
||||
|
||||
class TestExports:
|
||||
|
||||
@ -73,7 +73,7 @@ def _make_git(respx_mock=None) -> Git:
|
||||
"""Create a Git instance bound to a test capsule."""
|
||||
from wrenn.client import WrennClient
|
||||
|
||||
client = WrennClient(api_key="wrn_test1234567890abcdef12345678")
|
||||
client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
return Git(CAPSULE_ID, client.http)
|
||||
|
||||
|
||||
@ -81,7 +81,7 @@ def _make_async_git() -> AsyncGit:
|
||||
"""Create an AsyncGit instance bound to a test capsule."""
|
||||
from wrenn.client import AsyncWrennClient
|
||||
|
||||
client = AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678")
|
||||
client = AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
return AsyncGit(CAPSULE_ID, client.http)
|
||||
|
||||
|
||||
@ -926,7 +926,7 @@ class TestCapsuleWiring:
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201, json={"id": "cl-1", "status": "pending"}
|
||||
)
|
||||
cap = Capsule(api_key="wrn_test1234567890abcdef12345678")
|
||||
cap = Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
assert hasattr(cap, "git")
|
||||
assert isinstance(cap.git, Git)
|
||||
|
||||
@ -1017,7 +1017,7 @@ class TestCommandPayloadWrapping:
|
||||
from wrenn.client import WrennClient
|
||||
from wrenn.commands import Commands
|
||||
|
||||
client = WrennClient(api_key="wrn_test1234567890abcdef12345678")
|
||||
client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
commands = Commands(CAPSULE_ID, client.http)
|
||||
|
||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response(stdout="3\n"))
|
||||
@ -1031,7 +1031,7 @@ class TestCommandPayloadWrapping:
|
||||
from wrenn.client import WrennClient
|
||||
from wrenn.commands import Commands
|
||||
|
||||
client = WrennClient(api_key="wrn_test1234567890abcdef12345678")
|
||||
client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
commands = Commands(CAPSULE_ID, client.http)
|
||||
|
||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
||||
@ -1045,7 +1045,7 @@ class TestCommandPayloadWrapping:
|
||||
from wrenn.client import WrennClient
|
||||
from wrenn.commands import Commands
|
||||
|
||||
client = WrennClient(api_key="wrn_test1234567890abcdef12345678")
|
||||
client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
commands = Commands(CAPSULE_ID, client.http)
|
||||
|
||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
||||
@ -1059,7 +1059,7 @@ class TestCommandPayloadWrapping:
|
||||
from wrenn.client import WrennClient
|
||||
from wrenn.commands import Commands
|
||||
|
||||
client = WrennClient(api_key="wrn_test1234567890abcdef12345678")
|
||||
client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
commands = Commands(CAPSULE_ID, client.http)
|
||||
|
||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
||||
@ -1073,7 +1073,7 @@ class TestCommandPayloadWrapping:
|
||||
from wrenn.client import WrennClient
|
||||
from wrenn.commands import Commands
|
||||
|
||||
client = WrennClient(api_key="wrn_test1234567890abcdef12345678")
|
||||
client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
commands = Commands(CAPSULE_ID, client.http)
|
||||
|
||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
||||
@ -1089,7 +1089,7 @@ class TestCommandPayloadWrapping:
|
||||
from wrenn.client import WrennClient
|
||||
from wrenn.commands import Commands
|
||||
|
||||
client = WrennClient(api_key="wrn_test1234567890abcdef12345678")
|
||||
client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
commands = Commands(CAPSULE_ID, client.http)
|
||||
|
||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
||||
@ -1119,7 +1119,7 @@ class TestCommandPayloadWrapping:
|
||||
from wrenn.client import WrennClient
|
||||
from wrenn.commands import Commands
|
||||
|
||||
client = WrennClient(api_key="wrn_test1234567890abcdef12345678")
|
||||
client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
commands = Commands(CAPSULE_ID, client.http)
|
||||
|
||||
route = respx.post(EXEC_URL).respond(200, json={"pid": 42, "tag": "bg-1"})
|
||||
|
||||
@ -15,6 +15,17 @@ pytestmark = pytest.mark.integration
|
||||
_env_loaded = False
|
||||
|
||||
|
||||
def _wait_for_pid_dead(capsule: Capsule, pid: int, timeout: float = 5.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
result = capsule.commands.run(f"ps -p {pid} -o stat= 2>/dev/null || true")
|
||||
state = result.stdout.strip()
|
||||
if not state or state.startswith("Z"):
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_env() -> None:
|
||||
global _env_loaded
|
||||
if _env_loaded:
|
||||
@ -218,11 +229,7 @@ class TestCommands:
|
||||
def test_kill_process(self):
|
||||
handle = self.capsule.commands.run("sleep 30", background=True)
|
||||
self.capsule.commands.kill(handle.pid)
|
||||
time.sleep(0.5)
|
||||
|
||||
processes = self.capsule.commands.list()
|
||||
pids = [p.pid for p in processes]
|
||||
assert handle.pid not in pids
|
||||
assert _wait_for_pid_dead(self.capsule, handle.pid)
|
||||
|
||||
def test_run_duration_ms(self):
|
||||
result = self.capsule.commands.run("sleep 1")
|
||||
|
||||
95
uv.lock
generated
95
uv.lock
generated
@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 2
|
||||
requires-python = ">=3.13"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
@ -72,6 +72,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfgv"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.7"
|
||||
@ -226,6 +235,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.8.0"
|
||||
@ -282,6 +300,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.29.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "genson"
|
||||
version = "1.3.0"
|
||||
@ -343,6 +370,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/f8/a6bc80313a9e93c888fa10534dfce2ad76ff86911b6f485777ce6de6a073/httpx_ws-0.9.0-py3-none-any.whl", hash = "sha256:71640d2fb1bf9a225775015b33cd755cfd4c5f7e21c885192fe3adc4c387b248", size = 15759, upload-time = "2026-03-28T14:11:11.887Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.6.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@ -548,6 +584,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nr-date"
|
||||
version = "2.1.0"
|
||||
@ -615,6 +660,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "4.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cfgv" },
|
||||
{ name = "identify" },
|
||||
{ name = "nodeenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "virtualenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
@ -745,6 +806,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-discovery"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "filelock" },
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytokens"
|
||||
version = "0.4.1"
|
||||
@ -956,6 +1030,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "21.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "distlib" },
|
||||
{ name = "filelock" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "python-discovery" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/8b/6331f7a7fe70131c301106ec1e7cf23e2501bf7d4ca3636805801ca191bb/virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e", size = 7614069, upload-time = "2026-04-27T17:05:58.927Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/eb/03bfb1299d4c4510329e470f13f9a4ce793df7fcb5a2fd3510f911066f61/virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", size = 7594690, upload-time = "2026-04-27T17:05:55.468Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchdog"
|
||||
version = "6.0.0"
|
||||
@ -1032,7 +1121,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "wrenn"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "email-validator" },
|
||||
@ -1045,6 +1134,7 @@ dependencies = [
|
||||
dev = [
|
||||
{ name = "datamodel-code-generator", extra = ["ruff"] },
|
||||
{ name = "mypy" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pydoc-markdown" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
@ -1064,6 +1154,7 @@ requires-dist = [
|
||||
dev = [
|
||||
{ name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" },
|
||||
{ name = "mypy", specifier = ">=1.20.0" },
|
||||
{ name = "pre-commit", specifier = ">=4.6.0" },
|
||||
{ name = "pydoc-markdown", specifier = ">=4.8.2" },
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||
|
||||
Reference in New Issue
Block a user