Compare commits
18 Commits
feat/modul
...
fce514c49c
| Author | SHA1 | Date | |
|---|---|---|---|
| fce514c49c | |||
| 87cc16e9e2 | |||
| 08f6a1ab84 | |||
| 51c6987515 | |||
| 800a8566db | |||
| e057ec2407 | |||
| e5e4e1a85b | |||
| 6112c71abc | |||
| a42f0b2e71 | |||
| d9c028564e | |||
| 06b4a8cbcb | |||
| 04e5dc652f | |||
| 4a7db8e204 | |||
| a76be96682 | |||
| be573d07a3 | |||
| dc66ac24d5 | |||
| b5e2b12ef1 | |||
| 213af4aee7 |
7
.gitignore
vendored
7
.gitignore
vendored
@ -175,3 +175,10 @@ cython_debug/
|
|||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
CODE_EXECUTION.md
|
CODE_EXECUTION.md
|
||||||
|
|
||||||
|
.opencode/
|
||||||
|
# AI
|
||||||
|
.code-review-graph/
|
||||||
|
.claude
|
||||||
|
.mcp.json
|
||||||
|
AGENTS.md
|
||||||
|
|||||||
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:
|
when:
|
||||||
event: push
|
event: pull_request
|
||||||
branch:
|
branch:
|
||||||
- main
|
- main
|
||||||
- dev
|
- dev
|
||||||
|
|||||||
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.
|
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.
|
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.
|
||||||
|
|||||||
1378
api/openapi.yaml
1378
api/openapi.yaml
File diff suppressed because it is too large
Load Diff
@ -1964,15 +1964,17 @@ inactivity TTL is set.
|
|||||||
#### wait\_ready
|
#### wait\_ready
|
||||||
|
|
||||||
```python
|
```python
|
||||||
async def wait_ready(timeout: float = 30, interval: float = 0.5) -> None
|
async def wait_ready(timeout: float = 30) -> None
|
||||||
```
|
```
|
||||||
|
|
||||||
Await until the capsule status is ``running``.
|
Await until the capsule status is ``running``.
|
||||||
|
|
||||||
|
Polling interval adapts to the current transient status:
|
||||||
|
0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
|
||||||
|
|
||||||
**Arguments**:
|
**Arguments**:
|
||||||
|
|
||||||
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
|
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
|
||||||
- `interval` _float_ - Polling interval in seconds. Defaults to ``0.5``.
|
|
||||||
|
|
||||||
|
|
||||||
**Raises**:
|
**Raises**:
|
||||||
@ -2534,15 +2536,17 @@ inactivity TTL is set.
|
|||||||
#### wait\_ready
|
#### wait\_ready
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def wait_ready(timeout: float = 30, interval: float = 0.5) -> None
|
def wait_ready(timeout: float = 30) -> None
|
||||||
```
|
```
|
||||||
|
|
||||||
Block until the capsule status is ``running``.
|
Block until the capsule status is ``running``.
|
||||||
|
|
||||||
|
Polling interval adapts to the current transient status:
|
||||||
|
0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
|
||||||
|
|
||||||
**Arguments**:
|
**Arguments**:
|
||||||
|
|
||||||
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
|
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
|
||||||
- `interval` _float_ - Polling interval in seconds. Defaults to ``0.5``.
|
|
||||||
|
|
||||||
|
|
||||||
**Raises**:
|
**Raises**:
|
||||||
@ -2700,17 +2704,6 @@ Create a snapshot template from this capsule's current state.
|
|||||||
|
|
||||||
# wrenn.\_config
|
# wrenn.\_config
|
||||||
|
|
||||||
<a id="wrenn._config.ConnectionConfig"></a>
|
|
||||||
|
|
||||||
## ConnectionConfig Objects
|
|
||||||
|
|
||||||
```python
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ConnectionConfig()
|
|
||||||
```
|
|
||||||
|
|
||||||
Resolved credentials and base URL for Wrenn API calls.
|
|
||||||
|
|
||||||
<a id="wrenn._git._auth"></a>
|
<a id="wrenn._git._auth"></a>
|
||||||
|
|
||||||
# wrenn.\_git.\_auth
|
# wrenn.\_git.\_auth
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "wrenn"
|
name = "wrenn"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
description = "Python SDK for Wrenn"
|
description = "Python SDK for Wrenn"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@ -36,6 +36,7 @@ build-backend = "hatchling.build"
|
|||||||
dev = [
|
dev = [
|
||||||
"datamodel-code-generator[ruff]>=0.56.0",
|
"datamodel-code-generator[ruff]>=0.56.0",
|
||||||
"mypy>=1.20.0",
|
"mypy>=1.20.0",
|
||||||
|
"pre-commit>=4.6.0",
|
||||||
"pydoc-markdown>=4.8.2",
|
"pydoc-markdown>=4.8.2",
|
||||||
"pytest>=9.0.3",
|
"pytest>=9.0.3",
|
||||||
"pytest-asyncio>=1.3.0",
|
"pytest-asyncio>=1.3.0",
|
||||||
|
|||||||
@ -37,7 +37,7 @@ from wrenn.exceptions import (
|
|||||||
from wrenn.models import FileEntry
|
from wrenn.models import FileEntry
|
||||||
from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession
|
from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.4"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"__version__",
|
"__version__",
|
||||||
|
|||||||
@ -1,33 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
DEFAULT_BASE_URL = "https://app.wrenn.dev/api"
|
DEFAULT_BASE_URL = "https://app.wrenn.dev/api"
|
||||||
ENV_API_KEY = "WRENN_API_KEY"
|
ENV_API_KEY = "WRENN_API_KEY"
|
||||||
ENV_BASE_URL = "WRENN_BASE_URL"
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import builtins
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@ -8,15 +10,54 @@ from contextlib import asynccontextmanager
|
|||||||
import httpx_ws
|
import httpx_ws
|
||||||
|
|
||||||
from wrenn._git import AsyncGit
|
from wrenn._git import AsyncGit
|
||||||
from wrenn.capsule import _DualMethod, _build_proxy_url
|
from wrenn.capsule import (
|
||||||
|
_DEFAULT_WAIT_TIMEOUT,
|
||||||
|
_DESTROY_INTERVAL,
|
||||||
|
_FAIL_STATUSES,
|
||||||
|
_PAUSE_INTERVAL,
|
||||||
|
_RESUME_INTERVAL,
|
||||||
|
_START_INTERVAL,
|
||||||
|
_DualMethod,
|
||||||
|
_build_proxy_url,
|
||||||
|
)
|
||||||
from wrenn.client import AsyncWrennClient
|
from wrenn.client import AsyncWrennClient
|
||||||
from wrenn.commands import AsyncCommands
|
from wrenn.commands import AsyncCommands
|
||||||
|
from wrenn.exceptions import WrennNotFoundError
|
||||||
from wrenn.files import AsyncFiles
|
from wrenn.files import AsyncFiles
|
||||||
from wrenn.models import Capsule as CapsuleModel
|
from wrenn.models import Capsule as CapsuleModel
|
||||||
from wrenn.models import Status, Template
|
from wrenn.models import Status, Template
|
||||||
from wrenn.pty import AsyncPtySession
|
from wrenn.pty import AsyncPtySession
|
||||||
|
|
||||||
|
|
||||||
|
async def _apoll_until(
|
||||||
|
fetch,
|
||||||
|
targets: set[Status],
|
||||||
|
interval: float,
|
||||||
|
timeout: float = _DEFAULT_WAIT_TIMEOUT,
|
||||||
|
fail_on: set[Status] | None = None,
|
||||||
|
) -> CapsuleModel:
|
||||||
|
fail = fail_on if fail_on is not None else _FAIL_STATUSES
|
||||||
|
treat_missing_as_target = Status.missing in targets
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
last: CapsuleModel | None = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
last = await fetch()
|
||||||
|
except WrennNotFoundError:
|
||||||
|
if treat_missing_as_target:
|
||||||
|
return CapsuleModel(status=Status.missing)
|
||||||
|
raise
|
||||||
|
if last.status in targets:
|
||||||
|
return last
|
||||||
|
if last.status is not None and last.status in fail:
|
||||||
|
raise RuntimeError(f"Capsule entered {last.status} state while waiting")
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Capsule did not reach {targets} within {timeout}s "
|
||||||
|
f"(last status: {last.status if last else 'unknown'})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AsyncCapsule:
|
class AsyncCapsule:
|
||||||
"""Async Wrenn capsule with e2b-compatible interface.
|
"""Async Wrenn capsule with e2b-compatible interface.
|
||||||
|
|
||||||
@ -102,6 +143,7 @@ class AsyncCapsule:
|
|||||||
memory_mb=memory_mb,
|
memory_mb=memory_mb,
|
||||||
timeout_sec=timeout,
|
timeout_sec=timeout,
|
||||||
)
|
)
|
||||||
|
assert info.id is not None
|
||||||
capsule = cls(
|
capsule = cls(
|
||||||
_capsule_id=info.id,
|
_capsule_id=info.id,
|
||||||
_client=client,
|
_client=client,
|
||||||
@ -136,15 +178,21 @@ class AsyncCapsule:
|
|||||||
client = AsyncWrennClient(api_key=api_key, base_url=base_url)
|
client = AsyncWrennClient(api_key=api_key, base_url=base_url)
|
||||||
info = await client.capsules.get(capsule_id)
|
info = await client.capsules.get(capsule_id)
|
||||||
|
|
||||||
if info.status == Status.paused:
|
capsule = cls(
|
||||||
info = await client.capsules.resume(capsule_id)
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
_capsule_id=capsule_id,
|
_capsule_id=capsule_id,
|
||||||
_client=client,
|
_client=client,
|
||||||
_info=info,
|
_info=info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if info.status == Status.pausing:
|
||||||
|
info = await capsule._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
|
||||||
|
if info.status == Status.paused:
|
||||||
|
await client.capsules.resume(capsule_id)
|
||||||
|
if info.status != Status.running:
|
||||||
|
await capsule.wait_ready()
|
||||||
|
|
||||||
|
return capsule
|
||||||
|
|
||||||
# ── Dual instance/static lifecycle ──────────────────────────
|
# ── Dual instance/static lifecycle ──────────────────────────
|
||||||
|
|
||||||
destroy = _DualMethod("_instance_destroy", "_static_destroy")
|
destroy = _DualMethod("_instance_destroy", "_static_destroy")
|
||||||
@ -152,22 +200,35 @@ class AsyncCapsule:
|
|||||||
resume = _DualMethod("_instance_resume", "_static_resume")
|
resume = _DualMethod("_instance_resume", "_static_resume")
|
||||||
get_info = _DualMethod("_instance_get_info", "_static_get_info")
|
get_info = _DualMethod("_instance_get_info", "_static_get_info")
|
||||||
|
|
||||||
async def _instance_destroy(self) -> None:
|
async def _instance_destroy(self, wait: bool = False) -> None:
|
||||||
await self._client.capsules.destroy(self._id)
|
await self._client.capsules.destroy(self._id)
|
||||||
|
if wait:
|
||||||
|
await self._wait_for_status(
|
||||||
|
{Status.stopped, Status.missing}, _DESTROY_INTERVAL
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _static_destroy(
|
async def _static_destroy(
|
||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
await client.capsules.destroy(capsule_id)
|
await client.capsules.destroy(capsule_id)
|
||||||
|
if wait:
|
||||||
|
await _apoll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.stopped, Status.missing},
|
||||||
|
_DESTROY_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
async def _instance_pause(self) -> CapsuleModel:
|
async def _instance_pause(self, wait: bool = False) -> CapsuleModel:
|
||||||
self._info = await self._client.capsules.pause(self._id)
|
self._info = await self._client.capsules.pause(self._id)
|
||||||
|
if wait:
|
||||||
|
self._info = await self._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
|
||||||
return self._info
|
return self._info
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -175,14 +236,24 @@ class AsyncCapsule:
|
|||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> CapsuleModel:
|
) -> CapsuleModel:
|
||||||
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
return await client.capsules.pause(capsule_id)
|
info = await client.capsules.pause(capsule_id)
|
||||||
|
if wait:
|
||||||
|
info = await _apoll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.paused},
|
||||||
|
_PAUSE_INTERVAL,
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
async def _instance_resume(self) -> CapsuleModel:
|
async def _instance_resume(self, wait: bool = False) -> CapsuleModel:
|
||||||
self._info = await self._client.capsules.resume(self._id)
|
self._info = await self._client.capsules.resume(self._id)
|
||||||
|
if wait:
|
||||||
|
self._info = await self._wait_for_status({Status.running}, _RESUME_INTERVAL)
|
||||||
return self._info
|
return self._info
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -190,11 +261,19 @@ class AsyncCapsule:
|
|||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> CapsuleModel:
|
) -> CapsuleModel:
|
||||||
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
return await client.capsules.resume(capsule_id)
|
info = await client.capsules.resume(capsule_id)
|
||||||
|
if wait:
|
||||||
|
info = await _apoll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.running},
|
||||||
|
_RESUME_INTERVAL,
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
async def _instance_get_info(self) -> CapsuleModel:
|
async def _instance_get_info(self) -> CapsuleModel:
|
||||||
self._info = await self._client.capsules.get(self._id)
|
self._info = await self._client.capsules.get(self._id)
|
||||||
@ -221,29 +300,30 @@ class AsyncCapsule:
|
|||||||
"""
|
"""
|
||||||
await self._client.capsules.ping(self._id)
|
await self._client.capsules.ping(self._id)
|
||||||
|
|
||||||
async def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None:
|
async def _wait_for_status(
|
||||||
"""Await until the capsule status is ``running``.
|
self,
|
||||||
|
targets: set[Status],
|
||||||
|
interval: float,
|
||||||
|
timeout: float = _DEFAULT_WAIT_TIMEOUT,
|
||||||
|
) -> CapsuleModel:
|
||||||
|
info = await _apoll_until(
|
||||||
|
lambda: self._client.capsules.get(self._id),
|
||||||
|
targets,
|
||||||
|
interval,
|
||||||
|
timeout,
|
||||||
|
fail_on={Status.error, Status.stopped, Status.missing} - targets,
|
||||||
|
)
|
||||||
|
self._info = info
|
||||||
|
return info
|
||||||
|
|
||||||
Args:
|
async def wait_ready(self, timeout: float = _DEFAULT_WAIT_TIMEOUT) -> None:
|
||||||
timeout (float): Maximum seconds to wait. Defaults to ``30``.
|
"""Await until capsule status is ``running``.
|
||||||
interval (float): Polling interval in seconds. Defaults to ``0.5``.
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TimeoutError: If the capsule does not reach ``running`` state
|
TimeoutError: If capsule does not reach ``running`` within ``timeout``.
|
||||||
within ``timeout`` seconds.
|
RuntimeError: If capsule enters error/stopped/missing while waiting.
|
||||||
RuntimeError: If the capsule enters an error, stopped, or paused
|
|
||||||
state while waiting.
|
|
||||||
"""
|
"""
|
||||||
deadline = time.monotonic() + timeout
|
await self._wait_for_status({Status.running}, _START_INTERVAL, timeout)
|
||||||
while time.monotonic() < deadline:
|
|
||||||
info = await self._client.capsules.get(self._id)
|
|
||||||
if info.status == Status.running:
|
|
||||||
self._info = info
|
|
||||||
return
|
|
||||||
if info.status in (Status.error, Status.stopped, Status.paused):
|
|
||||||
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
|
||||||
|
|
||||||
async def is_running(self) -> bool:
|
async def is_running(self) -> bool:
|
||||||
"""Check whether the capsule is currently running.
|
"""Check whether the capsule is currently running.
|
||||||
@ -284,7 +364,7 @@ class AsyncCapsule:
|
|||||||
async def pty(
|
async def pty(
|
||||||
self,
|
self,
|
||||||
cmd: str = "/bin/bash",
|
cmd: str = "/bin/bash",
|
||||||
args: list[str] | None = None,
|
args: builtins.list[str] | None = None,
|
||||||
cols: int = 80,
|
cols: int = 80,
|
||||||
rows: int = 24,
|
rows: int = 24,
|
||||||
envs: dict[str, str] | None = None,
|
envs: dict[str, str] | None = None,
|
||||||
@ -316,7 +396,7 @@ class AsyncCapsule:
|
|||||||
"""
|
"""
|
||||||
async with httpx_ws.aconnect_ws(
|
async with httpx_ws.aconnect_ws(
|
||||||
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
||||||
) as ws:
|
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||||
session = AsyncPtySession(ws, self._id)
|
session = AsyncPtySession(ws, self._id)
|
||||||
await session._send_start(
|
await session._send_start(
|
||||||
cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd
|
cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd
|
||||||
@ -335,7 +415,7 @@ class AsyncCapsule:
|
|||||||
"""
|
"""
|
||||||
async with httpx_ws.aconnect_ws(
|
async with httpx_ws.aconnect_ws(
|
||||||
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
||||||
) as ws:
|
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||||
session = AsyncPtySession(ws, self._id)
|
session = AsyncPtySession(ws, self._id)
|
||||||
await session._send_connect(tag)
|
await session._send_connect(tag)
|
||||||
yield session
|
yield session
|
||||||
@ -387,8 +467,8 @@ class AsyncCapsule:
|
|||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
await self._instance_destroy()
|
await self._instance_destroy()
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
logging.warning("Failed to destroy capsule %s: %s", self._id, exc)
|
||||||
try:
|
try:
|
||||||
await self._client.aclose()
|
await self._client.aclose()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import builtins
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
@ -11,6 +13,7 @@ import httpx_ws
|
|||||||
from wrenn._git import Git
|
from wrenn._git import Git
|
||||||
from wrenn.client import WrennClient
|
from wrenn.client import WrennClient
|
||||||
from wrenn.commands import Commands
|
from wrenn.commands import Commands
|
||||||
|
from wrenn.exceptions import WrennNotFoundError
|
||||||
from wrenn.files import Files
|
from wrenn.files import Files
|
||||||
from wrenn.models import Capsule as CapsuleModel
|
from wrenn.models import Capsule as CapsuleModel
|
||||||
from wrenn.models import Status, Template
|
from wrenn.models import Status, Template
|
||||||
@ -26,6 +29,44 @@ def _build_proxy_url(base_url: str, capsule_id: str | None, port: int) -> str:
|
|||||||
return f"{scheme}://{port}-{capsule_id}.{host}"
|
return f"{scheme}://{port}-{capsule_id}.{host}"
|
||||||
|
|
||||||
|
|
||||||
|
_RESUME_INTERVAL = 0.5
|
||||||
|
_DESTROY_INTERVAL = 0.5
|
||||||
|
_PAUSE_INTERVAL = 2.0
|
||||||
|
_START_INTERVAL = 0.5
|
||||||
|
_DEFAULT_WAIT_TIMEOUT = 30.0
|
||||||
|
_FAIL_STATUSES = {Status.error}
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_until(
|
||||||
|
fetch,
|
||||||
|
targets: set[Status],
|
||||||
|
interval: float,
|
||||||
|
timeout: float = _DEFAULT_WAIT_TIMEOUT,
|
||||||
|
fail_on: set[Status] | None = None,
|
||||||
|
) -> CapsuleModel:
|
||||||
|
"""Poll ``fetch()`` until status ∈ ``targets``. Raise on ``fail_on``/timeout."""
|
||||||
|
fail = fail_on if fail_on is not None else _FAIL_STATUSES
|
||||||
|
treat_missing_as_target = Status.missing in targets
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
last: CapsuleModel | None = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
last = fetch()
|
||||||
|
except WrennNotFoundError:
|
||||||
|
if treat_missing_as_target:
|
||||||
|
return CapsuleModel(status=Status.missing)
|
||||||
|
raise
|
||||||
|
if last.status in targets:
|
||||||
|
return last
|
||||||
|
if last.status is not None and last.status in fail:
|
||||||
|
raise RuntimeError(f"Capsule entered {last.status} state while waiting")
|
||||||
|
time.sleep(interval)
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Capsule did not reach {targets} within {timeout}s "
|
||||||
|
f"(last status: {last.status if last else 'unknown'})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _DualMethod:
|
class _DualMethod:
|
||||||
"""Descriptor that dispatches to instance method or classmethod depending on call site."""
|
"""Descriptor that dispatches to instance method or classmethod depending on call site."""
|
||||||
|
|
||||||
@ -94,21 +135,25 @@ class Capsule:
|
|||||||
``WRENN_BASE_URL`` or the default production endpoint.
|
``WRENN_BASE_URL`` or the default production endpoint.
|
||||||
"""
|
"""
|
||||||
if _capsule_id is not None:
|
if _capsule_id is not None:
|
||||||
# Internal construction path (from create/connect classmethods)
|
|
||||||
assert _client is not None
|
assert _client is not None
|
||||||
self._id = _capsule_id
|
self._id: str = _capsule_id
|
||||||
self._client = _client
|
self._client = _client
|
||||||
self._info = _info
|
self._info = _info
|
||||||
else:
|
else:
|
||||||
# Public construction: create a capsule immediately
|
|
||||||
self._client = WrennClient(api_key=api_key, base_url=base_url)
|
self._client = WrennClient(api_key=api_key, base_url=base_url)
|
||||||
self._info = self._client.capsules.create(
|
try:
|
||||||
template=template,
|
self._info = self._client.capsules.create(
|
||||||
vcpus=vcpus,
|
template=template,
|
||||||
memory_mb=memory_mb,
|
vcpus=vcpus,
|
||||||
timeout_sec=timeout,
|
memory_mb=memory_mb,
|
||||||
)
|
timeout_sec=timeout,
|
||||||
self._id = self._info.id
|
)
|
||||||
|
if self._info.id is None:
|
||||||
|
raise RuntimeError("API returned a capsule without an ID")
|
||||||
|
self._id = self._info.id
|
||||||
|
except Exception:
|
||||||
|
self._client.close()
|
||||||
|
raise
|
||||||
|
|
||||||
self.commands = Commands(self._id, self._client.http)
|
self.commands = Commands(self._id, self._client.http)
|
||||||
self.files = Files(self._id, self._client.http)
|
self.files = Files(self._id, self._client.http)
|
||||||
@ -204,15 +249,21 @@ class Capsule:
|
|||||||
client = WrennClient(api_key=api_key, base_url=base_url)
|
client = WrennClient(api_key=api_key, base_url=base_url)
|
||||||
info = client.capsules.get(capsule_id)
|
info = client.capsules.get(capsule_id)
|
||||||
|
|
||||||
if info.status == Status.paused:
|
capsule = cls(
|
||||||
info = client.capsules.resume(capsule_id)
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
_capsule_id=capsule_id,
|
_capsule_id=capsule_id,
|
||||||
_client=client,
|
_client=client,
|
||||||
_info=info,
|
_info=info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if info.status == Status.pausing:
|
||||||
|
info = capsule._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
|
||||||
|
if info.status == Status.paused:
|
||||||
|
client.capsules.resume(capsule_id)
|
||||||
|
if info.status != Status.running:
|
||||||
|
capsule.wait_ready()
|
||||||
|
|
||||||
|
return capsule
|
||||||
|
|
||||||
# ── Dual instance/static lifecycle ──────────────────────────
|
# ── Dual instance/static lifecycle ──────────────────────────
|
||||||
|
|
||||||
destroy = _DualMethod("_instance_destroy", "_static_destroy")
|
destroy = _DualMethod("_instance_destroy", "_static_destroy")
|
||||||
@ -220,25 +271,36 @@ class Capsule:
|
|||||||
resume = _DualMethod("_instance_resume", "_static_resume")
|
resume = _DualMethod("_instance_resume", "_static_resume")
|
||||||
get_info = _DualMethod("_instance_get_info", "_static_get_info")
|
get_info = _DualMethod("_instance_get_info", "_static_get_info")
|
||||||
|
|
||||||
def _instance_destroy(self) -> None:
|
def _instance_destroy(self, wait: bool = False) -> None:
|
||||||
"""Destroy this capsule."""
|
"""Destroy this capsule. If ``wait``, poll until stopped/missing."""
|
||||||
self._client.capsules.destroy(self._id)
|
self._client.capsules.destroy(self._id)
|
||||||
|
if wait:
|
||||||
|
self._wait_for_status({Status.stopped, Status.missing}, _DESTROY_INTERVAL)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _static_destroy(
|
def _static_destroy(
|
||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Destroy a capsule by ID."""
|
"""Destroy a capsule by ID."""
|
||||||
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
client.capsules.destroy(capsule_id)
|
client.capsules.destroy(capsule_id)
|
||||||
|
if wait:
|
||||||
|
_poll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.stopped, Status.missing},
|
||||||
|
_DESTROY_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
def _instance_pause(self) -> CapsuleModel:
|
def _instance_pause(self, wait: bool = False) -> CapsuleModel:
|
||||||
"""Pause this capsule."""
|
"""Pause this capsule. If ``wait``, poll until ``paused``."""
|
||||||
self._info = self._client.capsules.pause(self._id)
|
self._info = self._client.capsules.pause(self._id)
|
||||||
|
if wait:
|
||||||
|
self._info = self._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
|
||||||
return self._info
|
return self._info
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -246,16 +308,26 @@ class Capsule:
|
|||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> CapsuleModel:
|
) -> CapsuleModel:
|
||||||
"""Pause a capsule by ID."""
|
"""Pause a capsule by ID."""
|
||||||
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
return client.capsules.pause(capsule_id)
|
info = client.capsules.pause(capsule_id)
|
||||||
|
if wait:
|
||||||
|
info = _poll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.paused},
|
||||||
|
_PAUSE_INTERVAL,
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
def _instance_resume(self) -> CapsuleModel:
|
def _instance_resume(self, wait: bool = False) -> CapsuleModel:
|
||||||
"""Resume this capsule."""
|
"""Resume this capsule. If ``wait``, poll until ``running``."""
|
||||||
self._info = self._client.capsules.resume(self._id)
|
self._info = self._client.capsules.resume(self._id)
|
||||||
|
if wait:
|
||||||
|
self._info = self._wait_for_status({Status.running}, _RESUME_INTERVAL)
|
||||||
return self._info
|
return self._info
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -263,12 +335,20 @@ class Capsule:
|
|||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> CapsuleModel:
|
) -> CapsuleModel:
|
||||||
"""Resume a capsule by ID."""
|
"""Resume a capsule by ID."""
|
||||||
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
return client.capsules.resume(capsule_id)
|
info = client.capsules.resume(capsule_id)
|
||||||
|
if wait:
|
||||||
|
info = _poll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.running},
|
||||||
|
_RESUME_INTERVAL,
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
def _instance_get_info(self) -> CapsuleModel:
|
def _instance_get_info(self) -> CapsuleModel:
|
||||||
"""Get current info for this capsule."""
|
"""Get current info for this capsule."""
|
||||||
@ -297,29 +377,30 @@ class Capsule:
|
|||||||
"""
|
"""
|
||||||
self._client.capsules.ping(self._id)
|
self._client.capsules.ping(self._id)
|
||||||
|
|
||||||
def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None:
|
def _wait_for_status(
|
||||||
"""Block until the capsule status is ``running``.
|
self,
|
||||||
|
targets: set[Status],
|
||||||
|
interval: float,
|
||||||
|
timeout: float = _DEFAULT_WAIT_TIMEOUT,
|
||||||
|
) -> CapsuleModel:
|
||||||
|
info = _poll_until(
|
||||||
|
lambda: self._client.capsules.get(self._id),
|
||||||
|
targets,
|
||||||
|
interval,
|
||||||
|
timeout,
|
||||||
|
fail_on={Status.error, Status.stopped, Status.missing} - targets,
|
||||||
|
)
|
||||||
|
self._info = info
|
||||||
|
return info
|
||||||
|
|
||||||
Args:
|
def wait_ready(self, timeout: float = _DEFAULT_WAIT_TIMEOUT) -> None:
|
||||||
timeout (float): Maximum seconds to wait. Defaults to ``30``.
|
"""Block until capsule status is ``running``.
|
||||||
interval (float): Polling interval in seconds. Defaults to ``0.5``.
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TimeoutError: If the capsule does not reach ``running`` state
|
TimeoutError: If capsule does not reach ``running`` within ``timeout``.
|
||||||
within ``timeout`` seconds.
|
RuntimeError: If capsule enters error/stopped/missing while waiting.
|
||||||
RuntimeError: If the capsule enters an error, stopped, or paused
|
|
||||||
state while waiting.
|
|
||||||
"""
|
"""
|
||||||
deadline = time.monotonic() + timeout
|
self._wait_for_status({Status.running}, _START_INTERVAL, timeout)
|
||||||
while time.monotonic() < deadline:
|
|
||||||
info = self._client.capsules.get(self._id)
|
|
||||||
if info.status == Status.running:
|
|
||||||
self._info = info
|
|
||||||
return
|
|
||||||
if info.status in (Status.error, Status.stopped, Status.paused):
|
|
||||||
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
|
||||||
time.sleep(interval)
|
|
||||||
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
|
||||||
|
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""Check whether the capsule is currently running.
|
"""Check whether the capsule is currently running.
|
||||||
@ -360,7 +441,7 @@ class Capsule:
|
|||||||
def pty(
|
def pty(
|
||||||
self,
|
self,
|
||||||
cmd: str = "/bin/bash",
|
cmd: str = "/bin/bash",
|
||||||
args: list[str] | None = None,
|
args: builtins.list[str] | None = None,
|
||||||
cols: int = 80,
|
cols: int = 80,
|
||||||
rows: int = 24,
|
rows: int = 24,
|
||||||
envs: dict[str, str] | None = None,
|
envs: dict[str, str] | None = None,
|
||||||
@ -391,7 +472,7 @@ class Capsule:
|
|||||||
"""
|
"""
|
||||||
with httpx_ws.connect_ws(
|
with httpx_ws.connect_ws(
|
||||||
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
||||||
) as ws:
|
) as ws: # type: httpx_ws.WebSocketSession
|
||||||
session = PtySession(ws, self._id)
|
session = PtySession(ws, self._id)
|
||||||
session._send_start(
|
session._send_start(
|
||||||
cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd
|
cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd
|
||||||
@ -410,7 +491,7 @@ class Capsule:
|
|||||||
"""
|
"""
|
||||||
with httpx_ws.connect_ws(
|
with httpx_ws.connect_ws(
|
||||||
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
||||||
) as ws:
|
) as ws: # type: httpx_ws.WebSocketSession
|
||||||
session = PtySession(ws, self._id)
|
session = PtySession(ws, self._id)
|
||||||
session._send_connect(tag)
|
session._send_connect(tag)
|
||||||
yield session
|
yield session
|
||||||
@ -462,8 +543,8 @@ class Capsule:
|
|||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
self._instance_destroy()
|
self._instance_destroy()
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
logging.warning("Failed to destroy capsule %s: %s", self._id, exc)
|
||||||
try:
|
try:
|
||||||
self._client.close()
|
self._client.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import httpx
|
|||||||
|
|
||||||
from wrenn._config import DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL
|
from wrenn._config import DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL
|
||||||
from wrenn.exceptions import handle_response
|
from wrenn.exceptions import handle_response
|
||||||
|
|
||||||
from wrenn.models import (
|
from wrenn.models import (
|
||||||
Template,
|
Template,
|
||||||
)
|
)
|
||||||
@ -13,6 +14,8 @@ from wrenn.models import (
|
|||||||
Capsule as CapsuleModel,
|
Capsule as CapsuleModel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_LONG_TIMEOUT = httpx.Timeout(60.0)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_api_key(api_key: str | None) -> str:
|
def _resolve_api_key(api_key: str | None) -> str:
|
||||||
resolved = api_key or os.environ.get(ENV_API_KEY)
|
resolved = api_key or os.environ.get(ENV_API_KEY)
|
||||||
@ -285,7 +288,9 @@ class SnapshotsResource:
|
|||||||
params: dict = {}
|
params: dict = {}
|
||||||
if overwrite:
|
if overwrite:
|
||||||
params["overwrite"] = "true"
|
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))
|
return Template.model_validate(handle_response(resp))
|
||||||
|
|
||||||
def list(self, type: str | None = None) -> list[Template]:
|
def list(self, type: str | None = None) -> list[Template]:
|
||||||
@ -347,7 +352,9 @@ class AsyncSnapshotsResource:
|
|||||||
params: dict = {}
|
params: dict = {}
|
||||||
if overwrite:
|
if overwrite:
|
||||||
params["overwrite"] = "true"
|
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))
|
return Template.model_validate(handle_response(resp))
|
||||||
|
|
||||||
async def list(self, type: str | None = None) -> list[Template]:
|
async def list(self, type: str | None = None) -> list[Template]:
|
||||||
|
|||||||
@ -40,6 +40,28 @@ class AsyncCapsule(BaseAsyncCapsule):
|
|||||||
self._kernel_id = None
|
self._kernel_id = None
|
||||||
self._proxy_client = 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
|
@classmethod
|
||||||
async def create(
|
async def create(
|
||||||
cls,
|
cls,
|
||||||
@ -126,8 +148,10 @@ class AsyncCapsule(BaseAsyncCapsule):
|
|||||||
request=resp.request,
|
request=resp.request,
|
||||||
response=resp,
|
response=resp,
|
||||||
)
|
)
|
||||||
except httpx.HTTPStatusError:
|
except httpx.HTTPStatusError as exc:
|
||||||
raise
|
if exc.response.status_code < 500:
|
||||||
|
raise
|
||||||
|
last_exc = exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_exc = exc
|
last_exc = exc
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
@ -164,8 +188,6 @@ class AsyncCapsule(BaseAsyncCapsule):
|
|||||||
},
|
},
|
||||||
"buffers": [],
|
"buffers": [],
|
||||||
"channel": "shell",
|
"channel": "shell",
|
||||||
"msg_id": msg_id,
|
|
||||||
"msg_type": "execute_request",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async def run_code(
|
async def run_code(
|
||||||
@ -201,13 +223,13 @@ class AsyncCapsule(BaseAsyncCapsule):
|
|||||||
ws_url = self._jupyter_ws_url(kernel_id)
|
ws_url = self._jupyter_ws_url(kernel_id)
|
||||||
|
|
||||||
msg = self._jupyter_execute_request(code)
|
msg = self._jupyter_execute_request(code)
|
||||||
msg_id = msg["msg_id"]
|
msg_id = msg["header"]["msg_id"]
|
||||||
|
|
||||||
execution = Execution()
|
execution = Execution()
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
headers = {"X-API-Key": self._client._api_key}
|
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))
|
await ws.send_text(json.dumps(msg))
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
time_left = deadline - time.monotonic()
|
time_left = deadline - time.monotonic()
|
||||||
@ -215,7 +237,7 @@ class AsyncCapsule(BaseAsyncCapsule):
|
|||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
data = await asyncio.wait_for(ws.receive_json(), timeout=time_left)
|
data = await asyncio.wait_for(ws.receive_json(), timeout=time_left)
|
||||||
except (asyncio.TimeoutError, Exception):
|
except Exception:
|
||||||
break
|
break
|
||||||
if not data:
|
if not data:
|
||||||
break
|
break
|
||||||
|
|||||||
@ -70,6 +70,17 @@ class Capsule(BaseCapsule):
|
|||||||
self._kernel_id = None
|
self._kernel_id = None
|
||||||
self._proxy_client = 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
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
cls,
|
cls,
|
||||||
@ -150,8 +161,10 @@ class Capsule(BaseCapsule):
|
|||||||
request=resp.request,
|
request=resp.request,
|
||||||
response=resp,
|
response=resp,
|
||||||
)
|
)
|
||||||
except httpx.HTTPStatusError:
|
except httpx.HTTPStatusError as exc:
|
||||||
raise
|
if exc.response.status_code < 500:
|
||||||
|
raise
|
||||||
|
last_exc = exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_exc = exc
|
last_exc = exc
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
@ -188,8 +201,6 @@ class Capsule(BaseCapsule):
|
|||||||
},
|
},
|
||||||
"buffers": [],
|
"buffers": [],
|
||||||
"channel": "shell",
|
"channel": "shell",
|
||||||
"msg_id": msg_id,
|
|
||||||
"msg_type": "execute_request",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def run_code(
|
def run_code(
|
||||||
@ -227,13 +238,13 @@ class Capsule(BaseCapsule):
|
|||||||
ws_url = self._jupyter_ws_url(kernel_id)
|
ws_url = self._jupyter_ws_url(kernel_id)
|
||||||
|
|
||||||
msg = self._jupyter_execute_request(code)
|
msg = self._jupyter_execute_request(code)
|
||||||
msg_id = msg["msg_id"]
|
msg_id = msg["header"]["msg_id"]
|
||||||
|
|
||||||
execution = Execution()
|
execution = Execution()
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
headers = {"X-API-Key": self._client._api_key}
|
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))
|
ws.send_text(json.dumps(msg))
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
time_left = deadline - time.monotonic()
|
time_left = deadline - time.monotonic()
|
||||||
@ -241,7 +252,7 @@ class Capsule(BaseCapsule):
|
|||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
data = ws.receive_json(timeout=time_left)
|
data = ws.receive_json(timeout=time_left)
|
||||||
except (TimeoutError, Exception):
|
except Exception:
|
||||||
break
|
break
|
||||||
if not data:
|
if not data:
|
||||||
break
|
break
|
||||||
|
|||||||
@ -1,16 +1,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import builtins
|
||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterator, Iterator
|
from collections.abc import AsyncIterator, Iterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import overload, Literal
|
from typing import Literal, overload
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import httpx_ws
|
import httpx_ws
|
||||||
|
|
||||||
from wrenn.exceptions import handle_response
|
from wrenn.exceptions import handle_response
|
||||||
|
|
||||||
|
# Both signal a terminated WebSocket: ``WebSocketDisconnect`` is a clean close,
|
||||||
|
# ``WebSocketNetworkError`` an abrupt one. The Wrenn server closes exec/process
|
||||||
|
# streams abruptly, so iterators must treat either as end-of-stream.
|
||||||
|
_WS_CLOSED = (httpx_ws.WebSocketDisconnect, httpx_ws.WebSocketNetworkError)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CommandResult:
|
class CommandResult:
|
||||||
@ -197,8 +203,17 @@ class Commands:
|
|||||||
if tag is not None:
|
if tag is not None:
|
||||||
payload["tag"] = tag
|
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)
|
data = handle_response(resp)
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
|
||||||
if background:
|
if background:
|
||||||
return CommandHandle(
|
return CommandHandle(
|
||||||
@ -217,6 +232,7 @@ class Commands:
|
|||||||
"""
|
"""
|
||||||
resp = self._http.get(f"/v1/capsules/{self._capsule_id}/processes")
|
resp = self._http.get(f"/v1/capsules/{self._capsule_id}/processes")
|
||||||
data = handle_response(resp)
|
data = handle_response(resp)
|
||||||
|
assert isinstance(data, dict)
|
||||||
return [
|
return [
|
||||||
ProcessInfo(
|
ProcessInfo(
|
||||||
pid=p.get("pid", 0),
|
pid=p.get("pid", 0),
|
||||||
@ -252,7 +268,7 @@ class Commands:
|
|||||||
with httpx_ws.connect_ws(
|
with httpx_ws.connect_ws(
|
||||||
f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream",
|
f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream",
|
||||||
self._http,
|
self._http,
|
||||||
) as ws:
|
) as ws: # type: httpx_ws.WebSocketSession
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
raw = ws.receive_json()
|
raw = ws.receive_json()
|
||||||
@ -260,10 +276,12 @@ class Commands:
|
|||||||
yield event
|
yield event
|
||||||
if event.type in ("exit", "error"):
|
if event.type in ("exit", "error"):
|
||||||
break
|
break
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
break
|
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.
|
"""Execute a command via WebSocket, streaming output as events.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -280,7 +298,7 @@ class Commands:
|
|||||||
with httpx_ws.connect_ws(
|
with httpx_ws.connect_ws(
|
||||||
f"/v1/capsules/{self._capsule_id}/exec/stream",
|
f"/v1/capsules/{self._capsule_id}/exec/stream",
|
||||||
self._http,
|
self._http,
|
||||||
) as ws:
|
) as ws: # type: httpx_ws.WebSocketSession
|
||||||
if args:
|
if args:
|
||||||
start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
|
start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
|
||||||
else:
|
else:
|
||||||
@ -293,7 +311,7 @@ class Commands:
|
|||||||
yield event
|
yield event
|
||||||
if event.type in ("exit", "error"):
|
if event.type in ("exit", "error"):
|
||||||
break
|
break
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
@ -374,10 +392,17 @@ class AsyncCommands:
|
|||||||
if tag is not None:
|
if tag is not None:
|
||||||
payload["tag"] = tag
|
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(
|
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)
|
data = handle_response(resp)
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
|
||||||
if background:
|
if background:
|
||||||
return CommandHandle(
|
return CommandHandle(
|
||||||
@ -396,6 +421,7 @@ class AsyncCommands:
|
|||||||
"""
|
"""
|
||||||
resp = await self._http.get(f"/v1/capsules/{self._capsule_id}/processes")
|
resp = await self._http.get(f"/v1/capsules/{self._capsule_id}/processes")
|
||||||
data = handle_response(resp)
|
data = handle_response(resp)
|
||||||
|
assert isinstance(data, dict)
|
||||||
return [
|
return [
|
||||||
ProcessInfo(
|
ProcessInfo(
|
||||||
pid=p.get("pid", 0),
|
pid=p.get("pid", 0),
|
||||||
@ -433,7 +459,7 @@ class AsyncCommands:
|
|||||||
async with httpx_ws.aconnect_ws(
|
async with httpx_ws.aconnect_ws(
|
||||||
f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream",
|
f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream",
|
||||||
self._http,
|
self._http,
|
||||||
) as ws:
|
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
raw = await ws.receive_json()
|
raw = await ws.receive_json()
|
||||||
@ -441,11 +467,11 @@ class AsyncCommands:
|
|||||||
yield event
|
yield event
|
||||||
if event.type in ("exit", "error"):
|
if event.type in ("exit", "error"):
|
||||||
break
|
break
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def stream(
|
async def stream(
|
||||||
self, cmd: str, args: list[str] | None = None
|
self, cmd: str, args: builtins.list[str] | None = None
|
||||||
) -> AsyncIterator[StreamEvent]:
|
) -> AsyncIterator[StreamEvent]:
|
||||||
"""Execute a command via WebSocket, streaming output as events.
|
"""Execute a command via WebSocket, streaming output as events.
|
||||||
|
|
||||||
@ -463,7 +489,7 @@ class AsyncCommands:
|
|||||||
async with httpx_ws.aconnect_ws(
|
async with httpx_ws.aconnect_ws(
|
||||||
f"/v1/capsules/{self._capsule_id}/exec/stream",
|
f"/v1/capsules/{self._capsule_id}/exec/stream",
|
||||||
self._http,
|
self._http,
|
||||||
) as ws:
|
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||||
if args:
|
if args:
|
||||||
start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
|
start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
|
||||||
else:
|
else:
|
||||||
@ -476,5 +502,5 @@ class AsyncCommands:
|
|||||||
yield event
|
yield event
|
||||||
if event.type in ("exit", "error"):
|
if event.type in ("exit", "error"):
|
||||||
break
|
break
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -110,37 +110,49 @@ _ERROR_MAP: dict[str, type[WrennError]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def handle_response(resp: httpx.Response) -> dict | list:
|
def _raise_for_status(resp: httpx.Response) -> None:
|
||||||
if resp.status_code >= 400:
|
if resp.status_code < 400:
|
||||||
try:
|
return
|
||||||
body = resp.json()
|
|
||||||
except Exception:
|
|
||||||
resp.raise_for_status()
|
|
||||||
raise
|
|
||||||
|
|
||||||
err = body.get("error", {})
|
try:
|
||||||
code = err.get("code", "internal_error")
|
body = resp.json()
|
||||||
message = err.get("message", resp.text)
|
except Exception:
|
||||||
|
raise WrennInternalError(
|
||||||
exc_cls = _ERROR_MAP.get(code, WrennError)
|
code="internal_error",
|
||||||
|
message=resp.text or f"HTTP {resp.status_code}",
|
||||||
if exc_cls is WrennHostHasCapsulesError:
|
|
||||||
raise WrennHostHasCapsulesError(
|
|
||||||
code=code,
|
|
||||||
message=message,
|
|
||||||
status_code=resp.status_code,
|
|
||||||
capsule_ids=body.get("sandbox_ids", []),
|
|
||||||
)
|
|
||||||
|
|
||||||
raise exc_cls(
|
|
||||||
code=code,
|
|
||||||
message=message,
|
|
||||||
status_code=resp.status_code,
|
status_code=resp.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
err = body.get("error", {})
|
||||||
|
code = err.get("code", "internal_error")
|
||||||
|
message = err.get("message", resp.text)
|
||||||
|
|
||||||
|
exc_cls = _ERROR_MAP.get(code, WrennError)
|
||||||
|
|
||||||
|
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:
|
if resp.status_code == 204:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
if not resp.content:
|
||||||
|
return {}
|
||||||
|
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -5,10 +5,40 @@ from collections.abc import AsyncIterator, Iterator
|
|||||||
|
|
||||||
import httpx
|
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
|
from wrenn.models import FileEntry, ListDirResponse, MakeDirResponse
|
||||||
|
|
||||||
|
|
||||||
|
def _is_already_exists(resp: httpx.Response) -> bool:
|
||||||
|
"""Detect server's already-exists reply across status codes / code strings.
|
||||||
|
|
||||||
|
Server may return 409 with code "conflict"/"already_exists" or wrap
|
||||||
|
"already_exists" inside an "internal" 500 message.
|
||||||
|
"""
|
||||||
|
if resp.status_code < 400:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
err = body.get("error", {}) if isinstance(body, dict) else {}
|
||||||
|
code = err.get("code", "")
|
||||||
|
msg = err.get("message", "") or ""
|
||||||
|
return code in {"conflict", "already_exists"} or "already_exists" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def _find_entry(list_fn, path: str) -> FileEntry | None:
|
||||||
|
parent = os.path.dirname(path)
|
||||||
|
name = os.path.basename(path)
|
||||||
|
try:
|
||||||
|
for entry in list_fn(parent, depth=1):
|
||||||
|
if entry.name == name:
|
||||||
|
return entry
|
||||||
|
except WrennNotFoundError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Files:
|
class Files:
|
||||||
"""Sync filesystem interface. Accessed via ``capsule.files``."""
|
"""Sync filesystem interface. Accessed via ``capsule.files``."""
|
||||||
|
|
||||||
@ -46,7 +76,7 @@ class Files:
|
|||||||
f"/v1/capsules/{self._capsule_id}/files/read",
|
f"/v1/capsules/{self._capsule_id}/files/read",
|
||||||
json={"path": path},
|
json={"path": path},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
_raise_for_status(resp)
|
||||||
return resp.content
|
return resp.content
|
||||||
|
|
||||||
def write(self, path: str, data: str | bytes) -> None:
|
def write(self, path: str, data: str | bytes) -> None:
|
||||||
@ -65,7 +95,7 @@ class Files:
|
|||||||
files={"file": ("upload", data)},
|
files={"file": ("upload", data)},
|
||||||
data={"path": path},
|
data={"path": path},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
_raise_for_status(resp)
|
||||||
|
|
||||||
def list(self, path: str, depth: int = 1) -> list[FileEntry]:
|
def list(self, path: str, depth: int = 1) -> list[FileEntry]:
|
||||||
"""List directory contents.
|
"""List directory contents.
|
||||||
@ -118,17 +148,10 @@ class Files:
|
|||||||
f"/v1/capsules/{self._capsule_id}/files/mkdir",
|
f"/v1/capsules/{self._capsule_id}/files/mkdir",
|
||||||
json={"path": path},
|
json={"path": path},
|
||||||
)
|
)
|
||||||
if resp.status_code == 409:
|
if _is_already_exists(resp):
|
||||||
try:
|
existing = _find_entry(self.list, path)
|
||||||
body = resp.json()
|
if existing is not None:
|
||||||
if body.get("error", {}).get("code") == "conflict":
|
return existing
|
||||||
parent = os.path.dirname(path)
|
|
||||||
name = os.path.basename(path)
|
|
||||||
for entry in self.list(parent, depth=1):
|
|
||||||
if entry.name == name:
|
|
||||||
return entry
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
parsed = MakeDirResponse.model_validate(handle_response(resp))
|
parsed = MakeDirResponse.model_validate(handle_response(resp))
|
||||||
if parsed.entry is None:
|
if parsed.entry is None:
|
||||||
raise RuntimeError("mkdir response missing entry")
|
raise RuntimeError("mkdir response missing entry")
|
||||||
@ -179,7 +202,7 @@ class Files:
|
|||||||
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}"
|
"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]:
|
def download_stream(self, path: str) -> Iterator[bytes]:
|
||||||
"""Stream a large file out of the capsule.
|
"""Stream a large file out of the capsule.
|
||||||
@ -243,7 +266,7 @@ class AsyncFiles:
|
|||||||
f"/v1/capsules/{self._capsule_id}/files/read",
|
f"/v1/capsules/{self._capsule_id}/files/read",
|
||||||
json={"path": path},
|
json={"path": path},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
_raise_for_status(resp)
|
||||||
return resp.content
|
return resp.content
|
||||||
|
|
||||||
async def write(self, path: str, data: str | bytes) -> None:
|
async def write(self, path: str, data: str | bytes) -> None:
|
||||||
@ -262,7 +285,7 @@ class AsyncFiles:
|
|||||||
files={"file": ("upload", data)},
|
files={"file": ("upload", data)},
|
||||||
data={"path": path},
|
data={"path": path},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
_raise_for_status(resp)
|
||||||
|
|
||||||
async def list(self, path: str, depth: int = 1) -> list[FileEntry]:
|
async def list(self, path: str, depth: int = 1) -> list[FileEntry]:
|
||||||
"""List directory contents.
|
"""List directory contents.
|
||||||
@ -315,17 +338,12 @@ class AsyncFiles:
|
|||||||
f"/v1/capsules/{self._capsule_id}/files/mkdir",
|
f"/v1/capsules/{self._capsule_id}/files/mkdir",
|
||||||
json={"path": path},
|
json={"path": path},
|
||||||
)
|
)
|
||||||
if resp.status_code == 409:
|
if _is_already_exists(resp):
|
||||||
try:
|
parent = os.path.dirname(path)
|
||||||
body = resp.json()
|
name = os.path.basename(path)
|
||||||
if body.get("error", {}).get("code") == "conflict":
|
for entry in await self.list(parent, depth=1):
|
||||||
parent = os.path.dirname(path)
|
if entry.name == name:
|
||||||
name = os.path.basename(path)
|
return entry
|
||||||
for entry in await self.list(parent, depth=1):
|
|
||||||
if entry.name == name:
|
|
||||||
return entry
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
parsed = MakeDirResponse.model_validate(handle_response(resp))
|
parsed = MakeDirResponse.model_validate(handle_response(resp))
|
||||||
if parsed.entry is None:
|
if parsed.entry is None:
|
||||||
raise RuntimeError("mkdir response missing entry")
|
raise RuntimeError("mkdir response missing entry")
|
||||||
@ -377,7 +395,7 @@ class AsyncFiles:
|
|||||||
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}"
|
"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]:
|
async def download_stream(self, path: str) -> AsyncIterator[bytes]:
|
||||||
"""Stream a large file out of the capsule.
|
"""Stream a large file out of the capsule.
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
from wrenn.models._generated import (
|
from wrenn.models._generated import (
|
||||||
APIKeyResponse,
|
APIKeyResponse,
|
||||||
AuthResponse,
|
|
||||||
Capsule,
|
Capsule,
|
||||||
CreateAPIKeyRequest,
|
CreateAPIKeyRequest,
|
||||||
CreateCapsuleRequest,
|
CreateCapsuleRequest,
|
||||||
@ -34,7 +33,6 @@ from wrenn.models._generated import (
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"APIKeyResponse",
|
"APIKeyResponse",
|
||||||
"AuthResponse",
|
|
||||||
"CreateAPIKeyRequest",
|
"CreateAPIKeyRequest",
|
||||||
"CreateHostRequest",
|
"CreateHostRequest",
|
||||||
"CreateHostResponse",
|
"CreateHostResponse",
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
# generated by datamodel-codegen:
|
# generated by datamodel-codegen:
|
||||||
# filename: openapi.yaml
|
# filename: openapi.yaml
|
||||||
# timestamp: 2026-04-22T20:21:34+00:00
|
# timestamp: 2026-05-19T08:54:50+00:00
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
|
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
|
||||||
from typing import Annotated
|
from typing import Annotated, Any
|
||||||
from datetime import date as date_aliased
|
from datetime import date as date_aliased
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
|
||||||
@ -27,14 +27,20 @@ class SignupResponse(BaseModel):
|
|||||||
] = None
|
] = None
|
||||||
|
|
||||||
|
|
||||||
class AuthResponse(BaseModel):
|
class SessionResponse(BaseModel):
|
||||||
token: Annotated[str | None, Field(description="JWT token (valid for 6 hours)")] = (
|
"""
|
||||||
None
|
Returned by login, activate, and switch-team. The actual auth credential
|
||||||
)
|
is the wrenn_sid cookie set on the response. The body carries identity
|
||||||
|
data the SPA needs to bootstrap.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
user_id: str | None = None
|
user_id: str | None = None
|
||||||
team_id: str | None = None
|
team_id: str | None = None
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
is_admin: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class CreateAPIKeyRequest(BaseModel):
|
class CreateAPIKeyRequest(BaseModel):
|
||||||
@ -62,10 +68,17 @@ class CreateCapsuleRequest(BaseModel):
|
|||||||
template: str | None = "minimal"
|
template: str | None = "minimal"
|
||||||
vcpus: int | None = 1
|
vcpus: int | None = 1
|
||||||
memory_mb: int | None = 512
|
memory_mb: int | None = 512
|
||||||
|
disk_size_mb: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(
|
||||||
|
description="Maximum size of the per-capsule copy-on-write disk in MB. Capped at 5 GB by default; the actual size is max(disk_size_mb, origin rootfs size).\n"
|
||||||
|
),
|
||||||
|
] = 5120
|
||||||
timeout_sec: Annotated[
|
timeout_sec: Annotated[
|
||||||
int | None,
|
int | None,
|
||||||
Field(
|
Field(
|
||||||
description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause.\n"
|
description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause. Positive values below 60 are silently clamped to 60 (the agent's startup envelope).\n",
|
||||||
|
ge=0,
|
||||||
),
|
),
|
||||||
] = 0
|
] = 0
|
||||||
|
|
||||||
@ -133,7 +146,10 @@ class Status(StrEnum):
|
|||||||
pending = "pending"
|
pending = "pending"
|
||||||
starting = "starting"
|
starting = "starting"
|
||||||
running = "running"
|
running = "running"
|
||||||
|
pausing = "pausing"
|
||||||
paused = "paused"
|
paused = "paused"
|
||||||
|
resuming = "resuming"
|
||||||
|
stopping = "stopping"
|
||||||
hibernated = "hibernated"
|
hibernated = "hibernated"
|
||||||
stopped = "stopped"
|
stopped = "stopped"
|
||||||
missing = "missing"
|
missing = "missing"
|
||||||
@ -153,6 +169,13 @@ class Capsule(BaseModel):
|
|||||||
started_at: AwareDatetime | None = None
|
started_at: AwareDatetime | None = None
|
||||||
last_active_at: AwareDatetime | None = None
|
last_active_at: AwareDatetime | None = None
|
||||||
last_updated: AwareDatetime | None = None
|
last_updated: AwareDatetime | None = None
|
||||||
|
metadata: Annotated[
|
||||||
|
dict[str, str] | None,
|
||||||
|
Field(
|
||||||
|
description="Free-form key/value labels attached at create-time. Also carries\nagent-side version info (kernel_version, vmm_version,\nagent_version, envd_version) when running.\n"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
disk_size_mb: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class CreateSnapshotRequest(BaseModel):
|
class CreateSnapshotRequest(BaseModel):
|
||||||
@ -177,6 +200,13 @@ class Template(BaseModel):
|
|||||||
memory_mb: int | None = None
|
memory_mb: int | None = None
|
||||||
size_bytes: int | None = None
|
size_bytes: int | None = None
|
||||||
created_at: AwareDatetime | None = None
|
created_at: AwareDatetime | None = None
|
||||||
|
platform: Annotated[
|
||||||
|
bool | None,
|
||||||
|
Field(
|
||||||
|
description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal` rootfs). False for team-owned\nsnapshot templates.\n"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
metadata: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ExecRequest(BaseModel):
|
class ExecRequest(BaseModel):
|
||||||
@ -399,7 +429,7 @@ class HostDeletePreview(BaseModel):
|
|||||||
host: Host | None = None
|
host: Host | None = None
|
||||||
sandbox_ids: Annotated[
|
sandbox_ids: Annotated[
|
||||||
list[str] | None,
|
list[str] | None,
|
||||||
Field(description="IDs of capsulees that would be destroyed on force-delete."),
|
Field(description="IDs of capsules that would be destroyed on force-delete."),
|
||||||
] = None
|
] = None
|
||||||
|
|
||||||
|
|
||||||
@ -407,8 +437,7 @@ class Error(BaseModel):
|
|||||||
code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None
|
code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None
|
||||||
message: str | None = None
|
message: str | None = None
|
||||||
sandbox_ids: Annotated[
|
sandbox_ids: Annotated[
|
||||||
list[str] | None,
|
list[str] | None, Field(description="IDs of active capsules blocking deletion.")
|
||||||
Field(description="IDs of active capsulees blocking deletion."),
|
|
||||||
] = None
|
] = None
|
||||||
|
|
||||||
|
|
||||||
@ -476,7 +505,9 @@ class MetricPoint(BaseModel):
|
|||||||
] = None
|
] = None
|
||||||
mem_bytes: Annotated[
|
mem_bytes: Annotated[
|
||||||
int | None,
|
int | None,
|
||||||
Field(description="Resident memory in bytes (VmRSS of Firecracker process)"),
|
Field(
|
||||||
|
description="Resident memory in bytes (VmRSS of Cloud Hypervisor process)"
|
||||||
|
),
|
||||||
] = None
|
] = None
|
||||||
disk_bytes: Annotated[
|
disk_bytes: Annotated[
|
||||||
int | None, Field(description="Allocated disk bytes for the CoW sparse file")
|
int | None, Field(description="Allocated disk bytes for the CoW sparse file")
|
||||||
@ -494,12 +525,12 @@ class Provider(StrEnum):
|
|||||||
|
|
||||||
|
|
||||||
class Event(StrEnum):
|
class Event(StrEnum):
|
||||||
capsule_created = "capsule.created"
|
capsule_create = "capsule.create"
|
||||||
capsule_running = "capsule.running"
|
capsule_pause = "capsule.pause"
|
||||||
capsule_paused = "capsule.paused"
|
capsule_resume = "capsule.resume"
|
||||||
capsule_destroyed = "capsule.destroyed"
|
capsule_destroy = "capsule.destroy"
|
||||||
template_snapshot_created = "template.snapshot.created"
|
template_snapshot_create = "template.snapshot.create"
|
||||||
template_snapshot_deleted = "template.snapshot.deleted"
|
template_snapshot_delete = "template.snapshot.delete"
|
||||||
host_up = "host.up"
|
host_up = "host.up"
|
||||||
host_down = "host.down"
|
host_down = "host.down"
|
||||||
|
|
||||||
@ -591,6 +622,106 @@ class Error1(BaseModel):
|
|||||||
error: Error2 | None = None
|
error: Error2 | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ActorType(StrEnum):
|
||||||
|
user = "user"
|
||||||
|
api_key = "api_key"
|
||||||
|
host = "host"
|
||||||
|
system = "system"
|
||||||
|
|
||||||
|
|
||||||
|
class Status2(StrEnum):
|
||||||
|
success = "success"
|
||||||
|
failure = "failure"
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogEntry(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
actor_type: ActorType | None = None
|
||||||
|
actor_id: str | None = None
|
||||||
|
actor_name: str | None = None
|
||||||
|
resource_type: str | None = None
|
||||||
|
resource_id: str | None = None
|
||||||
|
action: str | None = None
|
||||||
|
scope: str | None = None
|
||||||
|
status: Status2 | None = None
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
created_at: AwareDatetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Event2(StrEnum):
|
||||||
|
connected = "connected"
|
||||||
|
capsule_create = "capsule.create"
|
||||||
|
capsule_pause = "capsule.pause"
|
||||||
|
capsule_resume = "capsule.resume"
|
||||||
|
capsule_destroy = "capsule.destroy"
|
||||||
|
capsule_state_changed = "capsule.state.changed"
|
||||||
|
template_snapshot_create = "template.snapshot.create"
|
||||||
|
template_snapshot_delete = "template.snapshot.delete"
|
||||||
|
host_up = "host.up"
|
||||||
|
host_down = "host.down"
|
||||||
|
|
||||||
|
|
||||||
|
class Outcome(StrEnum):
|
||||||
|
"""
|
||||||
|
Present for action events (capsule.* except state.changed,
|
||||||
|
template.snapshot.*). Absent for host.up/down, capsule.state.changed,
|
||||||
|
and the connected sentinel.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
success = "success"
|
||||||
|
error = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class Resource(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Type4(StrEnum):
|
||||||
|
user = "user"
|
||||||
|
api_key = "api_key"
|
||||||
|
system = "system"
|
||||||
|
|
||||||
|
|
||||||
|
class Actor(BaseModel):
|
||||||
|
type: Type4 | None = None
|
||||||
|
id: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SSEEvent(BaseModel):
|
||||||
|
"""
|
||||||
|
Wire format of one SSE message body. The event name (`event:` line) is
|
||||||
|
the `kind` and the JSON below is the `data:` line.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
event: Event2 | None = None
|
||||||
|
outcome: Annotated[
|
||||||
|
Outcome | None,
|
||||||
|
Field(
|
||||||
|
description="Present for action events (capsule.* except state.changed,\ntemplate.snapshot.*). Absent for host.up/down, capsule.state.changed,\nand the connected sentinel.\n"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
resource: Resource | None = None
|
||||||
|
actor: Actor | None = None
|
||||||
|
metadata: Annotated[
|
||||||
|
dict[str, str] | None,
|
||||||
|
Field(
|
||||||
|
description="Event-specific context. Examples: `reason` (ttl_expired,\nhost_failure, cleanup_after_create_error, orphaned),\n`host_ip`, `from`/`to` (for capsule.state.changed).\n"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
error: Annotated[
|
||||||
|
str | None, Field(description="Failure reason; only set when outcome=error.")
|
||||||
|
] = None
|
||||||
|
sandbox: Annotated[
|
||||||
|
Capsule | None,
|
||||||
|
Field(description="Populated for capsule.* events; null if DB lookup failed."),
|
||||||
|
] = None
|
||||||
|
timestamp: AwareDatetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class ListDirResponse(BaseModel):
|
class ListDirResponse(BaseModel):
|
||||||
entries: list[FileEntry] | None = None
|
entries: list[FileEntry] | None = None
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,10 @@ from typing import Any
|
|||||||
import httpx_ws
|
import httpx_ws
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
# A clean (``WebSocketDisconnect``) or abrupt (``WebSocketNetworkError``) close
|
||||||
|
# both mean the PTY stream has ended; iteration must stop on either.
|
||||||
|
_WS_CLOSED = (httpx_ws.WebSocketDisconnect, httpx_ws.WebSocketNetworkError)
|
||||||
|
|
||||||
|
|
||||||
class PtyEventType(StrEnum):
|
class PtyEventType(StrEnum):
|
||||||
started = "started"
|
started = "started"
|
||||||
@ -109,6 +113,13 @@ class PtySession:
|
|||||||
def _send_connect(self, tag: str) -> None:
|
def _send_connect(self, tag: str) -> None:
|
||||||
self._ws.send_text(json.dumps({"type": "connect", "tag": tag}))
|
self._ws.send_text(json.dumps({"type": "connect", "tag": tag}))
|
||||||
|
|
||||||
|
def _send_pong(self) -> None:
|
||||||
|
"""Reply to a server keepalive ``ping`` so the session stays open."""
|
||||||
|
try:
|
||||||
|
self._ws.send_text(json.dumps({"type": "pong"}))
|
||||||
|
except _WS_CLOSED:
|
||||||
|
pass
|
||||||
|
|
||||||
def write(self, data: bytes) -> None:
|
def write(self, data: bytes) -> None:
|
||||||
"""Send raw bytes to the PTY stdin.
|
"""Send raw bytes to the PTY stdin.
|
||||||
|
|
||||||
@ -144,7 +155,7 @@ class PtySession:
|
|||||||
raise StopIteration
|
raise StopIteration
|
||||||
try:
|
try:
|
||||||
raw = self._ws.receive_text()
|
raw = self._ws.receive_text()
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
raise StopIteration
|
raise StopIteration
|
||||||
event = _parse_pty_event(json.loads(raw))
|
event = _parse_pty_event(json.loads(raw))
|
||||||
if event.type == PtyEventType.started:
|
if event.type == PtyEventType.started:
|
||||||
@ -152,8 +163,11 @@ class PtySession:
|
|||||||
self._tag = event.tag
|
self._tag = event.tag
|
||||||
if event.pid is not None:
|
if event.pid is not None:
|
||||||
self._pid = event.pid
|
self._pid = event.pid
|
||||||
|
if event.type == PtyEventType.ping:
|
||||||
|
self._send_pong()
|
||||||
if event.type == PtyEventType.exit:
|
if event.type == PtyEventType.exit:
|
||||||
raise StopIteration
|
self._done = True
|
||||||
|
return event
|
||||||
if event.type == PtyEventType.error and event.fatal:
|
if event.type == PtyEventType.error and event.fatal:
|
||||||
self._done = True
|
self._done = True
|
||||||
return event
|
return event
|
||||||
@ -235,6 +249,13 @@ class AsyncPtySession:
|
|||||||
async def _send_connect(self, tag: str) -> None:
|
async def _send_connect(self, tag: str) -> None:
|
||||||
await self._ws.send_text(json.dumps({"type": "connect", "tag": tag}))
|
await self._ws.send_text(json.dumps({"type": "connect", "tag": tag}))
|
||||||
|
|
||||||
|
async def _send_pong(self) -> None:
|
||||||
|
"""Reply to a server keepalive ``ping`` so the session stays open."""
|
||||||
|
try:
|
||||||
|
await self._ws.send_text(json.dumps({"type": "pong"}))
|
||||||
|
except _WS_CLOSED:
|
||||||
|
pass
|
||||||
|
|
||||||
async def write(self, data: bytes) -> None:
|
async def write(self, data: bytes) -> None:
|
||||||
"""Send raw bytes to the PTY stdin.
|
"""Send raw bytes to the PTY stdin.
|
||||||
|
|
||||||
@ -272,7 +293,7 @@ class AsyncPtySession:
|
|||||||
raise StopAsyncIteration
|
raise StopAsyncIteration
|
||||||
try:
|
try:
|
||||||
raw = await self._ws.receive_text()
|
raw = await self._ws.receive_text()
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
raise StopAsyncIteration
|
raise StopAsyncIteration
|
||||||
event = _parse_pty_event(json.loads(raw))
|
event = _parse_pty_event(json.loads(raw))
|
||||||
if event.type == PtyEventType.started:
|
if event.type == PtyEventType.started:
|
||||||
@ -280,8 +301,11 @@ class AsyncPtySession:
|
|||||||
self._tag = event.tag
|
self._tag = event.tag
|
||||||
if event.pid is not None:
|
if event.pid is not None:
|
||||||
self._pid = event.pid
|
self._pid = event.pid
|
||||||
|
if event.type == PtyEventType.ping:
|
||||||
|
await self._send_pong()
|
||||||
if event.type == PtyEventType.exit:
|
if event.type == PtyEventType.exit:
|
||||||
raise StopAsyncIteration
|
self._done = True
|
||||||
|
return event
|
||||||
if event.type == PtyEventType.error and event.fatal:
|
if event.type == PtyEventType.error and event.fatal:
|
||||||
self._done = True
|
self._done = True
|
||||||
return event
|
return event
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
import respx
|
import respx
|
||||||
|
|
||||||
from wrenn.capsule import Capsule, _build_proxy_url
|
from wrenn.capsule import Capsule, _build_proxy_url
|
||||||
@ -30,9 +31,13 @@ class TestCapsuleCreate:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
def test_capsule_constructor_creates(self):
|
def test_capsule_constructor_creates(self):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "cl-1", "status": "pending", "template": "minimal"}
|
202, json={"id": "cl-1", "status": "starting", "template": "minimal"}
|
||||||
|
)
|
||||||
|
cap = Capsule(
|
||||||
|
template="minimal",
|
||||||
|
api_key="wrn_test1234567890abcdef12345678",
|
||||||
|
base_url=BASE,
|
||||||
)
|
)
|
||||||
cap = Capsule(template="minimal", api_key="wrn_test1234567890abcdef12345678")
|
|
||||||
assert cap.capsule_id == "cl-1"
|
assert cap.capsule_id == "cl-1"
|
||||||
assert hasattr(cap, "commands")
|
assert hasattr(cap, "commands")
|
||||||
assert hasattr(cap, "files")
|
assert hasattr(cap, "files")
|
||||||
@ -40,18 +45,18 @@ class TestCapsuleCreate:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
def test_capsule_create_classmethod(self):
|
def test_capsule_create_classmethod(self):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "cl-2", "status": "pending"}
|
202, json={"id": "cl-2", "status": "starting"}
|
||||||
)
|
)
|
||||||
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678")
|
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||||
assert cap.capsule_id == "cl-2"
|
assert cap.capsule_id == "cl-2"
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_capsule_context_manager_kills(self):
|
def test_capsule_context_manager_kills(self):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "cl-1", "status": "pending"}
|
202, json={"id": "cl-1", "status": "starting"}
|
||||||
)
|
)
|
||||||
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
|
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
|
||||||
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 cap.capsule_id == "cl-1"
|
||||||
assert kill_route.called
|
assert kill_route.called
|
||||||
|
|
||||||
@ -59,33 +64,37 @@ class TestCapsuleCreate:
|
|||||||
def test_capsule_env_var(self, monkeypatch):
|
def test_capsule_env_var(self, monkeypatch):
|
||||||
monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env_key")
|
monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env_key")
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "cl-3", "status": "pending"}
|
202, json={"id": "cl-3", "status": "starting"}
|
||||||
)
|
)
|
||||||
cap = Capsule()
|
cap = Capsule(base_url=BASE)
|
||||||
assert cap.capsule_id == "cl-3"
|
assert cap.capsule_id == "cl-3"
|
||||||
|
|
||||||
|
|
||||||
class TestCapsuleStaticMethods:
|
class TestCapsuleStaticMethods:
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_static_destroy(self):
|
def test_static_destroy(self):
|
||||||
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
|
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
|
||||||
Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678")
|
Capsule._static_destroy(
|
||||||
|
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||||
|
)
|
||||||
assert route.called
|
assert route.called
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_static_pause(self):
|
def test_static_pause(self):
|
||||||
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
|
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
|
||||||
200, json={"id": "cl-1", "status": "paused"}
|
202, json={"id": "cl-1", "status": "pausing"}
|
||||||
)
|
)
|
||||||
info = Capsule._static_pause("cl-1", api_key="wrn_test1234567890abcdef12345678")
|
info = Capsule._static_pause(
|
||||||
assert info.status.value == "paused"
|
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||||
|
)
|
||||||
|
assert info.status.value == "pausing"
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_static_list(self):
|
def test_static_list(self):
|
||||||
respx.get(f"{BASE}/v1/capsules").respond(
|
respx.get(f"{BASE}/v1/capsules").respond(
|
||||||
200, json=[{"id": "cl-1", "status": "running"}]
|
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 len(items) == 1
|
||||||
assert items[0].id == "cl-1"
|
assert items[0].id == "cl-1"
|
||||||
|
|
||||||
@ -95,7 +104,7 @@ class TestCapsuleStaticMethods:
|
|||||||
200, json={"id": "cl-1", "status": "running"}
|
200, json={"id": "cl-1", "status": "running"}
|
||||||
)
|
)
|
||||||
info = Capsule._static_get_info(
|
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"
|
assert info.id == "cl-1"
|
||||||
|
|
||||||
@ -106,18 +115,24 @@ class TestCapsuleConnect:
|
|||||||
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
||||||
200, json={"id": "cl-1", "status": "running"}
|
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"
|
assert cap.capsule_id == "cl-1"
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_connect_paused_resumes(self):
|
def test_connect_paused_resumes(self):
|
||||||
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
get_route = respx.get(f"{BASE}/v1/capsules/cl-1")
|
||||||
200, json={"id": "cl-1", "status": "paused"}
|
get_route.side_effect = [
|
||||||
)
|
httpx.Response(200, json={"id": "cl-1", "status": "paused"}),
|
||||||
|
httpx.Response(200, json={"id": "cl-1", "status": "running"}),
|
||||||
|
]
|
||||||
respx.post(f"{BASE}/v1/capsules/cl-1/resume").respond(
|
respx.post(f"{BASE}/v1/capsules/cl-1/resume").respond(
|
||||||
200, json={"id": "cl-1", "status": "running"}
|
202, json={"id": "cl-1", "status": "resuming"}
|
||||||
|
)
|
||||||
|
cap = Capsule.connect(
|
||||||
|
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||||
)
|
)
|
||||||
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678")
|
|
||||||
assert cap.capsule_id == "cl-1"
|
assert cap.capsule_id == "cl-1"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -23,23 +23,23 @@ BASE = "https://app.wrenn.dev/api"
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
with WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as c:
|
||||||
yield c
|
yield c
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def async_client():
|
def async_client():
|
||||||
return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678")
|
return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||||
|
|
||||||
|
|
||||||
class TestCapsules:
|
class TestCapsules:
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_create(self, client):
|
def test_create(self, client):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201,
|
202,
|
||||||
json={
|
json={
|
||||||
"id": "sb-1",
|
"id": "sb-1",
|
||||||
"status": "pending",
|
"status": "starting",
|
||||||
"template": "base-python",
|
"template": "base-python",
|
||||||
"vcpus": 2,
|
"vcpus": 2,
|
||||||
"memory_mb": 1024,
|
"memory_mb": 1024,
|
||||||
@ -48,12 +48,12 @@ class TestCapsules:
|
|||||||
resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024)
|
resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024)
|
||||||
assert isinstance(resp, Capsule)
|
assert isinstance(resp, Capsule)
|
||||||
assert resp.id == "sb-1"
|
assert resp.id == "sb-1"
|
||||||
assert resp.status == Status.pending
|
assert resp.status == Status.starting
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_create_defaults(self, client):
|
def test_create_defaults(self, client):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "sb-2", "status": "pending"}
|
202, json={"id": "sb-2", "status": "starting"}
|
||||||
)
|
)
|
||||||
resp = client.capsules.create()
|
resp = client.capsules.create()
|
||||||
assert resp.id == "sb-2"
|
assert resp.id == "sb-2"
|
||||||
@ -77,25 +77,25 @@ class TestCapsules:
|
|||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_destroy(self, client):
|
def test_destroy(self, client):
|
||||||
route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(204)
|
route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(202)
|
||||||
client.capsules.destroy("sb-1")
|
client.capsules.destroy("sb-1")
|
||||||
assert route.called
|
assert route.called
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_pause(self, client):
|
def test_pause(self, client):
|
||||||
respx.post(f"{BASE}/v1/capsules/sb-1/pause").respond(
|
respx.post(f"{BASE}/v1/capsules/sb-1/pause").respond(
|
||||||
200, json={"id": "sb-1", "status": "paused"}
|
202, json={"id": "sb-1", "status": "pausing"}
|
||||||
)
|
)
|
||||||
resp = client.capsules.pause("sb-1")
|
resp = client.capsules.pause("sb-1")
|
||||||
assert resp.status == Status.paused
|
assert resp.status == Status.pausing
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_resume(self, client):
|
def test_resume(self, client):
|
||||||
respx.post(f"{BASE}/v1/capsules/sb-1/resume").respond(
|
respx.post(f"{BASE}/v1/capsules/sb-1/resume").respond(
|
||||||
200, json={"id": "sb-1", "status": "running"}
|
202, json={"id": "sb-1", "status": "resuming"}
|
||||||
)
|
)
|
||||||
resp = client.capsules.resume("sb-1")
|
resp = client.capsules.resume("sb-1")
|
||||||
assert resp.status == Status.running
|
assert resp.status == Status.resuming
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_ping(self, client):
|
def test_ping(self, client):
|
||||||
@ -221,7 +221,8 @@ class TestAuthModes:
|
|||||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
||||||
assert c._http.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
|
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"):
|
with pytest.raises(ValueError, match="No API key"):
|
||||||
WrennClient()
|
WrennClient()
|
||||||
|
|
||||||
@ -237,7 +238,7 @@ class TestAsyncClient:
|
|||||||
async def test_async_capsules_create(self, async_client):
|
async def test_async_capsules_create(self, async_client):
|
||||||
async with async_client:
|
async with async_client:
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "sb-1", "status": "pending"}
|
202, json={"id": "sb-1", "status": "starting"}
|
||||||
)
|
)
|
||||||
resp = await async_client.capsules.create(template="base-python")
|
resp = await async_client.capsules.create(template="base-python")
|
||||||
assert resp.id == "sb-1"
|
assert resp.id == "sb-1"
|
||||||
|
|||||||
490
tests/test_commands.py
Normal file
490
tests/test_commands.py
Normal file
@ -0,0 +1,490 @@
|
|||||||
|
"""Unit tests for wrenn.commands — Commands / AsyncCommands.
|
||||||
|
|
||||||
|
Covers payload construction (cwd, envs, tag, timeout), foreground/background
|
||||||
|
dispatch, base64 response decoding, stream-event parsing, and the
|
||||||
|
WebSocket-backed ``stream`` / ``connect`` iterators (with a fake WS).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from contextlib import asynccontextmanager, contextmanager
|
||||||
|
|
||||||
|
import httpx_ws
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from wrenn.client import AsyncWrennClient, WrennClient
|
||||||
|
from wrenn.commands import (
|
||||||
|
AsyncCommands,
|
||||||
|
CommandHandle,
|
||||||
|
CommandResult,
|
||||||
|
Commands,
|
||||||
|
ProcessInfo,
|
||||||
|
StreamErrorEvent,
|
||||||
|
StreamEvent,
|
||||||
|
StreamExitEvent,
|
||||||
|
StreamStartEvent,
|
||||||
|
StreamStderrEvent,
|
||||||
|
StreamStdoutEvent,
|
||||||
|
_decode_exec_response,
|
||||||
|
_parse_stream_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
BASE = "https://app.wrenn.dev/api"
|
||||||
|
CAPSULE_ID = "cl-cmd123"
|
||||||
|
EXEC_URL = f"{BASE}/v1/capsules/{CAPSULE_ID}/exec"
|
||||||
|
PROC_URL = f"{BASE}/v1/capsules/{CAPSULE_ID}/processes"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_commands() -> Commands:
|
||||||
|
client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||||
|
return Commands(CAPSULE_ID, client.http)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_async_commands() -> AsyncCommands:
|
||||||
|
client = AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||||
|
return AsyncCommands(CAPSULE_ID, client.http)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _decode_exec_response ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecodeExecResponse:
|
||||||
|
def test_plain_text(self):
|
||||||
|
result = _decode_exec_response(
|
||||||
|
{"stdout": "hello\n", "stderr": "", "exit_code": 0, "duration_ms": 12}
|
||||||
|
)
|
||||||
|
assert isinstance(result, CommandResult)
|
||||||
|
assert result.stdout == "hello\n"
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.duration_ms == 12
|
||||||
|
|
||||||
|
def test_base64_stdout(self):
|
||||||
|
encoded = base64.b64encode(b"binary\xff\x00out").decode()
|
||||||
|
result = _decode_exec_response(
|
||||||
|
{"stdout": encoded, "encoding": "base64", "exit_code": 0}
|
||||||
|
)
|
||||||
|
assert "binary" in result.stdout
|
||||||
|
|
||||||
|
def test_base64_stderr(self):
|
||||||
|
out = base64.b64encode(b"ok").decode()
|
||||||
|
err = base64.b64encode(b"warning").decode()
|
||||||
|
result = _decode_exec_response(
|
||||||
|
{"stdout": out, "stderr": err, "encoding": "base64", "exit_code": 1}
|
||||||
|
)
|
||||||
|
assert result.stdout == "ok"
|
||||||
|
assert result.stderr == "warning"
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
def test_missing_fields_default(self):
|
||||||
|
result = _decode_exec_response({})
|
||||||
|
assert result.stdout == ""
|
||||||
|
assert result.stderr == ""
|
||||||
|
assert result.exit_code == -1
|
||||||
|
assert result.duration_ms is None
|
||||||
|
|
||||||
|
def test_null_stdout_coerced_to_empty(self):
|
||||||
|
result = _decode_exec_response({"stdout": None, "stderr": None})
|
||||||
|
assert result.stdout == ""
|
||||||
|
assert result.stderr == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── _parse_stream_event ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseStreamEvent:
|
||||||
|
def test_start(self):
|
||||||
|
event = _parse_stream_event({"type": "start", "pid": 99})
|
||||||
|
assert isinstance(event, StreamStartEvent)
|
||||||
|
assert event.type == "start"
|
||||||
|
assert event.pid == 99
|
||||||
|
|
||||||
|
def test_stdout(self):
|
||||||
|
event = _parse_stream_event({"type": "stdout", "data": "out"})
|
||||||
|
assert isinstance(event, StreamStdoutEvent)
|
||||||
|
assert event.data == "out"
|
||||||
|
|
||||||
|
def test_stderr(self):
|
||||||
|
event = _parse_stream_event({"type": "stderr", "data": "err"})
|
||||||
|
assert isinstance(event, StreamStderrEvent)
|
||||||
|
assert event.data == "err"
|
||||||
|
|
||||||
|
def test_exit(self):
|
||||||
|
event = _parse_stream_event({"type": "exit", "exit_code": 7})
|
||||||
|
assert isinstance(event, StreamExitEvent)
|
||||||
|
assert event.exit_code == 7
|
||||||
|
|
||||||
|
def test_error(self):
|
||||||
|
event = _parse_stream_event({"type": "error", "data": "boom"})
|
||||||
|
assert isinstance(event, StreamErrorEvent)
|
||||||
|
assert event.data == "boom"
|
||||||
|
|
||||||
|
def test_unknown_type(self):
|
||||||
|
event = _parse_stream_event({"type": "weird"})
|
||||||
|
assert isinstance(event, StreamEvent)
|
||||||
|
assert event.type == "weird"
|
||||||
|
|
||||||
|
def test_missing_type(self):
|
||||||
|
event = _parse_stream_event({})
|
||||||
|
assert event.type == "unknown"
|
||||||
|
|
||||||
|
def test_exit_missing_code_defaults(self):
|
||||||
|
event = _parse_stream_event({"type": "exit"})
|
||||||
|
assert isinstance(event, StreamExitEvent)
|
||||||
|
assert event.exit_code == -1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Commands.run — payload construction ───────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunPayload:
|
||||||
|
@respx.mock
|
||||||
|
def test_foreground_basic_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"stdout": "hi", "exit_code": 0})
|
||||||
|
result = _make_commands().run("echo hi")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["cmd"] == "/bin/sh"
|
||||||
|
assert body["args"] == ["-c", "echo hi"]
|
||||||
|
assert body["background"] is False
|
||||||
|
assert body["timeout_sec"] == 30
|
||||||
|
assert result.stdout == "hi"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_cwd_in_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("pwd", cwd="/tmp/work")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["cwd"] == "/tmp/work"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_cwd_omitted_when_none(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("pwd")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert "cwd" not in body
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_envs_in_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("env", envs={"FOO": "bar", "BAZ": "qux"})
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["envs"] == {"FOO": "bar", "BAZ": "qux"}
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_empty_envs_still_sent(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("env", envs={})
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["envs"] == {}
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_tag_in_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("echo x", tag="my-tag")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["tag"] == "my-tag"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_custom_timeout_in_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("sleep 1", timeout=120)
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["timeout_sec"] == 120
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_timeout_none_omits_field(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("echo x", timeout=None)
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert "timeout_sec" not in body
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_all_kwargs_combined(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("echo x", timeout=60, envs={"A": "1"}, cwd="/srv", tag="t")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["cwd"] == "/srv"
|
||||||
|
assert body["envs"] == {"A": "1"}
|
||||||
|
assert body["tag"] == "t"
|
||||||
|
assert body["timeout_sec"] == 60
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunBackground:
|
||||||
|
@respx.mock
|
||||||
|
def test_background_returns_handle(self):
|
||||||
|
respx.post(EXEC_URL).respond(200, json={"pid": 1234, "tag": "bg"})
|
||||||
|
handle = _make_commands().run("sleep 100", background=True)
|
||||||
|
assert isinstance(handle, CommandHandle)
|
||||||
|
assert handle.pid == 1234
|
||||||
|
assert handle.tag == "bg"
|
||||||
|
assert handle.capsule_id == CAPSULE_ID
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_background_omits_timeout_sec(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"pid": 1, "tag": "x"})
|
||||||
|
_make_commands().run("sleep 100", background=True, timeout=30)
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert "timeout_sec" not in body
|
||||||
|
assert body["background"] is True
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_background_carries_cwd_and_envs(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"pid": 5, "tag": "t"})
|
||||||
|
_make_commands().run(
|
||||||
|
"server", background=True, cwd="/app", envs={"PORT": "80"}, tag="srv"
|
||||||
|
)
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["cwd"] == "/app"
|
||||||
|
assert body["envs"] == {"PORT": "80"}
|
||||||
|
assert body["tag"] == "srv"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_background_missing_pid_defaults_zero(self):
|
||||||
|
respx.post(EXEC_URL).respond(200, json={"tag": "x"})
|
||||||
|
handle = _make_commands().run("x", background=True)
|
||||||
|
assert handle.pid == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestListAndKill:
|
||||||
|
@respx.mock
|
||||||
|
def test_list_parses_processes(self):
|
||||||
|
respx.get(PROC_URL).respond(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"processes": [
|
||||||
|
{
|
||||||
|
"pid": 10,
|
||||||
|
"tag": "web",
|
||||||
|
"cmd": "/bin/sh",
|
||||||
|
"args": ["-c", "serve"],
|
||||||
|
},
|
||||||
|
{"pid": 11},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
procs = _make_commands().list()
|
||||||
|
assert len(procs) == 2
|
||||||
|
assert isinstance(procs[0], ProcessInfo)
|
||||||
|
assert procs[0].pid == 10
|
||||||
|
assert procs[0].tag == "web"
|
||||||
|
assert procs[0].args == ["-c", "serve"]
|
||||||
|
assert procs[1].pid == 11
|
||||||
|
assert procs[1].tag is None
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_list_empty(self):
|
||||||
|
respx.get(PROC_URL).respond(200, json={"processes": []})
|
||||||
|
assert _make_commands().list() == []
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_list_missing_key(self):
|
||||||
|
respx.get(PROC_URL).respond(200, json={})
|
||||||
|
assert _make_commands().list() == []
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_kill_sends_delete(self):
|
||||||
|
route = respx.delete(f"{PROC_URL}/42").respond(204)
|
||||||
|
_make_commands().kill(42)
|
||||||
|
assert route.called
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_kill_unknown_pid_raises(self):
|
||||||
|
from wrenn.exceptions import WrennNotFoundError
|
||||||
|
|
||||||
|
respx.delete(f"{PROC_URL}/999").respond(
|
||||||
|
404, json={"error": {"code": "not_found", "message": "no such process"}}
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennNotFoundError):
|
||||||
|
_make_commands().kill(999)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fake WebSocket plumbing for stream / connect ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeWS:
|
||||||
|
"""Synchronous fake WebSocket session."""
|
||||||
|
|
||||||
|
def __init__(self, messages: list) -> None:
|
||||||
|
self._messages = list(messages)
|
||||||
|
self.sent: list[str] = []
|
||||||
|
|
||||||
|
def send_text(self, text: str) -> None:
|
||||||
|
self.sent.append(text)
|
||||||
|
|
||||||
|
def receive_json(self) -> dict:
|
||||||
|
if not self._messages:
|
||||||
|
raise httpx_ws.WebSocketDisconnect()
|
||||||
|
msg = self._messages.pop(0)
|
||||||
|
if isinstance(msg, Exception):
|
||||||
|
raise msg
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
class _AsyncFakeWS:
|
||||||
|
"""Asynchronous fake WebSocket session."""
|
||||||
|
|
||||||
|
def __init__(self, messages: list) -> None:
|
||||||
|
self._messages = list(messages)
|
||||||
|
self.sent: list[str] = []
|
||||||
|
|
||||||
|
async def send_text(self, text: str) -> None:
|
||||||
|
self.sent.append(text)
|
||||||
|
|
||||||
|
async def receive_json(self) -> dict:
|
||||||
|
if not self._messages:
|
||||||
|
raise httpx_ws.WebSocketDisconnect()
|
||||||
|
msg = self._messages.pop(0)
|
||||||
|
if isinstance(msg, Exception):
|
||||||
|
raise msg
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_sync_ws(monkeypatch, ws: _FakeWS) -> None:
|
||||||
|
@contextmanager
|
||||||
|
def _fake_connect(url, client):
|
||||||
|
yield ws
|
||||||
|
|
||||||
|
monkeypatch.setattr("wrenn.commands.httpx_ws.connect_ws", _fake_connect)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_async_ws(monkeypatch, ws: _AsyncFakeWS) -> None:
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _fake_aconnect(url, client):
|
||||||
|
yield ws
|
||||||
|
|
||||||
|
monkeypatch.setattr("wrenn.commands.httpx_ws.aconnect_ws", _fake_aconnect)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Commands.stream ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestStream:
|
||||||
|
def test_stream_sends_shell_wrapped_start(self, monkeypatch):
|
||||||
|
ws = _FakeWS([{"type": "exit", "exit_code": 0}])
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
list(_make_commands().stream("echo hi"))
|
||||||
|
start = json.loads(ws.sent[0])
|
||||||
|
assert start == {"type": "start", "cmd": "/bin/sh", "args": ["-c", "echo hi"]}
|
||||||
|
|
||||||
|
def test_stream_with_explicit_args(self, monkeypatch):
|
||||||
|
ws = _FakeWS([{"type": "exit", "exit_code": 0}])
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
list(_make_commands().stream("/usr/bin/env", args=["python", "-V"]))
|
||||||
|
start = json.loads(ws.sent[0])
|
||||||
|
assert start == {
|
||||||
|
"type": "start",
|
||||||
|
"cmd": "/usr/bin/env",
|
||||||
|
"args": ["python", "-V"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_stream_yields_events_until_exit(self, monkeypatch):
|
||||||
|
ws = _FakeWS(
|
||||||
|
[
|
||||||
|
{"type": "start", "pid": 3},
|
||||||
|
{"type": "stdout", "data": "line1"},
|
||||||
|
{"type": "stderr", "data": "warn"},
|
||||||
|
{"type": "exit", "exit_code": 0},
|
||||||
|
{"type": "stdout", "data": "after-exit-ignored"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
events = list(_make_commands().stream("echo line1"))
|
||||||
|
assert [e.type for e in events] == ["start", "stdout", "stderr", "exit"]
|
||||||
|
|
||||||
|
def test_stream_stops_on_error(self, monkeypatch):
|
||||||
|
ws = _FakeWS([{"type": "error", "data": "fatal"}])
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
events = list(_make_commands().stream("bad"))
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].type == "error"
|
||||||
|
|
||||||
|
def test_stream_handles_disconnect(self, monkeypatch):
|
||||||
|
ws = _FakeWS([{"type": "stdout", "data": "x"}]) # then disconnect
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
events = list(_make_commands().stream("echo x"))
|
||||||
|
assert [e.type for e in events] == ["stdout"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Commands.connect ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestConnect:
|
||||||
|
def test_connect_yields_until_exit(self, monkeypatch):
|
||||||
|
ws = _FakeWS(
|
||||||
|
[
|
||||||
|
{"type": "stdout", "data": "tick"},
|
||||||
|
{"type": "exit", "exit_code": 0},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
events = list(_make_commands().connect(55))
|
||||||
|
assert [e.type for e in events] == ["stdout", "exit"]
|
||||||
|
|
||||||
|
def test_connect_handles_disconnect(self, monkeypatch):
|
||||||
|
ws = _FakeWS([]) # immediate disconnect
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
assert list(_make_commands().connect(1)) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── AsyncCommands ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncCommands:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_run_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"stdout": "hi", "exit_code": 0})
|
||||||
|
cmds = _make_async_commands()
|
||||||
|
result = await cmds.run("echo hi", cwd="/tmp", envs={"K": "v"}, tag="z")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["cwd"] == "/tmp"
|
||||||
|
assert body["envs"] == {"K": "v"}
|
||||||
|
assert body["tag"] == "z"
|
||||||
|
assert result.stdout == "hi"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_run_background(self):
|
||||||
|
respx.post(EXEC_URL).respond(200, json={"pid": 7, "tag": "bg"})
|
||||||
|
handle = await _make_async_commands().run("sleep 1", background=True)
|
||||||
|
assert isinstance(handle, CommandHandle)
|
||||||
|
assert handle.pid == 7
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_list(self):
|
||||||
|
respx.get(PROC_URL).respond(200, json={"processes": [{"pid": 1, "tag": "a"}]})
|
||||||
|
procs = await _make_async_commands().list()
|
||||||
|
assert len(procs) == 1
|
||||||
|
assert procs[0].pid == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_kill(self):
|
||||||
|
route = respx.delete(f"{PROC_URL}/3").respond(204)
|
||||||
|
await _make_async_commands().kill(3)
|
||||||
|
assert route.called
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_stream(self, monkeypatch):
|
||||||
|
ws = _AsyncFakeWS(
|
||||||
|
[
|
||||||
|
{"type": "start", "pid": 1},
|
||||||
|
{"type": "stdout", "data": "out"},
|
||||||
|
{"type": "exit", "exit_code": 0},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
_patch_async_ws(monkeypatch, ws)
|
||||||
|
events = [e async for e in _make_async_commands().stream("echo out")]
|
||||||
|
assert [e.type for e in events] == ["start", "stdout", "exit"]
|
||||||
|
start = json.loads(ws.sent[0])
|
||||||
|
assert start["cmd"] == "/bin/sh"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_connect(self, monkeypatch):
|
||||||
|
ws = _AsyncFakeWS([{"type": "exit", "exit_code": 0}])
|
||||||
|
_patch_async_ws(monkeypatch, ws)
|
||||||
|
events = [e async for e in _make_async_commands().connect(9)]
|
||||||
|
assert [e.type for e in events] == ["exit"]
|
||||||
@ -23,7 +23,7 @@ def _make_capsule(cap_id: str = "cl-abc") -> Capsule:
|
|||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": cap_id, "status": "running"}
|
201, json={"id": cap_id, "status": "running"}
|
||||||
)
|
)
|
||||||
return Capsule(api_key="wrn_test1234567890abcdef12345678")
|
return Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||||
|
|
||||||
|
|
||||||
class TestFilesRead:
|
class TestFilesRead:
|
||||||
@ -311,12 +311,14 @@ class TestPtySessionIteration:
|
|||||||
ws.receive_text.side_effect = messages
|
ws.receive_text.side_effect = messages
|
||||||
session = PtySession(ws, "cl-abc")
|
session = PtySession(ws, "cl-abc")
|
||||||
events = list(session)
|
events = list(session)
|
||||||
assert len(events) == 2
|
assert len(events) == 3
|
||||||
assert events[0].type == PtyEventType.started
|
assert events[0].type == PtyEventType.started
|
||||||
assert session.tag == "pty-abc12345"
|
assert session.tag == "pty-abc12345"
|
||||||
assert session.pid == 1
|
assert session.pid == 1
|
||||||
assert events[1].type == PtyEventType.output
|
assert events[1].type == PtyEventType.output
|
||||||
assert events[1].data == b"hello"
|
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):
|
def test_iter_stops_on_fatal_error(self):
|
||||||
ws = MagicMock()
|
ws = MagicMock()
|
||||||
@ -339,6 +341,39 @@ class TestPtySessionIteration:
|
|||||||
assert events == []
|
assert events == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestPtySessionPong:
|
||||||
|
def test_ping_triggers_pong(self):
|
||||||
|
ws = MagicMock()
|
||||||
|
ws.receive_text.side_effect = [
|
||||||
|
json.dumps({"type": "ping"}),
|
||||||
|
json.dumps({"type": "exit", "exit_code": 0}),
|
||||||
|
]
|
||||||
|
session = PtySession(ws, "cl-abc")
|
||||||
|
events = list(session)
|
||||||
|
assert events[0].type == PtyEventType.ping
|
||||||
|
sent = [json.loads(c[0][0]) for c in ws.send_text.call_args_list]
|
||||||
|
assert {"type": "pong"} in sent
|
||||||
|
|
||||||
|
def test_no_pong_without_ping(self):
|
||||||
|
ws = MagicMock()
|
||||||
|
ws.receive_text.side_effect = [
|
||||||
|
json.dumps({"type": "output", "data": ""}),
|
||||||
|
json.dumps({"type": "exit", "exit_code": 0}),
|
||||||
|
]
|
||||||
|
session = PtySession(ws, "cl-abc")
|
||||||
|
list(session)
|
||||||
|
sent = [json.loads(c[0][0]) for c in ws.send_text.call_args_list]
|
||||||
|
assert {"type": "pong"} not in sent
|
||||||
|
|
||||||
|
def test_send_pong_swallows_closed_ws(self):
|
||||||
|
import httpx_ws
|
||||||
|
|
||||||
|
ws = MagicMock()
|
||||||
|
ws.send_text.side_effect = httpx_ws.WebSocketNetworkError()
|
||||||
|
session = PtySession(ws, "cl-abc")
|
||||||
|
session._send_pong() # must not raise
|
||||||
|
|
||||||
|
|
||||||
class TestPtySessionContextManager:
|
class TestPtySessionContextManager:
|
||||||
def test_exit_kills_and_closes(self):
|
def test_exit_kills_and_closes(self):
|
||||||
ws = MagicMock()
|
ws = MagicMock()
|
||||||
@ -448,6 +483,28 @@ class TestAsyncPtySession:
|
|||||||
assert sent["cmd"] == "/bin/zsh"
|
assert sent["cmd"] == "/bin/zsh"
|
||||||
assert sent["cols"] == 100
|
assert sent["cols"] == 100
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_ping_triggers_pong(self):
|
||||||
|
ws = AsyncMock()
|
||||||
|
ws.receive_text.side_effect = [
|
||||||
|
json.dumps({"type": "ping"}),
|
||||||
|
json.dumps({"type": "exit", "exit_code": 0}),
|
||||||
|
]
|
||||||
|
session = AsyncPtySession(ws, "cl-abc")
|
||||||
|
events = [e async for e in session]
|
||||||
|
assert events[0].type == PtyEventType.ping
|
||||||
|
sent = [json.loads(c[0][0]) for c in ws.send_text.call_args_list]
|
||||||
|
assert {"type": "pong"} in sent
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_send_pong_swallows_closed_ws(self):
|
||||||
|
import httpx_ws
|
||||||
|
|
||||||
|
ws = AsyncMock()
|
||||||
|
ws.send_text.side_effect = httpx_ws.WebSocketNetworkError()
|
||||||
|
session = AsyncPtySession(ws, "cl-abc")
|
||||||
|
await session._send_pong() # must not raise
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_iteration(self):
|
async def test_async_iteration(self):
|
||||||
ws = AsyncMock()
|
ws = AsyncMock()
|
||||||
@ -461,10 +518,11 @@ class TestAsyncPtySession:
|
|||||||
events = []
|
events = []
|
||||||
async for event in session:
|
async for event in session:
|
||||||
events.append(event)
|
events.append(event)
|
||||||
assert len(events) == 2
|
assert len(events) == 3
|
||||||
assert events[0].type == PtyEventType.started
|
assert events[0].type == PtyEventType.started
|
||||||
assert session.tag == "pty-xyz"
|
assert session.tag == "pty-xyz"
|
||||||
assert session.pid == 5
|
assert session.pid == 5
|
||||||
|
assert events[2].type == PtyEventType.exit
|
||||||
|
|
||||||
|
|
||||||
class TestExports:
|
class TestExports:
|
||||||
|
|||||||
@ -73,7 +73,7 @@ def _make_git(respx_mock=None) -> Git:
|
|||||||
"""Create a Git instance bound to a test capsule."""
|
"""Create a Git instance bound to a test capsule."""
|
||||||
from wrenn.client import WrennClient
|
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)
|
return Git(CAPSULE_ID, client.http)
|
||||||
|
|
||||||
|
|
||||||
@ -81,7 +81,7 @@ def _make_async_git() -> AsyncGit:
|
|||||||
"""Create an AsyncGit instance bound to a test capsule."""
|
"""Create an AsyncGit instance bound to a test capsule."""
|
||||||
from wrenn.client import AsyncWrennClient
|
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)
|
return AsyncGit(CAPSULE_ID, client.http)
|
||||||
|
|
||||||
|
|
||||||
@ -926,7 +926,7 @@ class TestCapsuleWiring:
|
|||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "cl-1", "status": "pending"}
|
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 hasattr(cap, "git")
|
||||||
assert isinstance(cap.git, Git)
|
assert isinstance(cap.git, Git)
|
||||||
|
|
||||||
@ -1017,7 +1017,7 @@ class TestCommandPayloadWrapping:
|
|||||||
from wrenn.client import WrennClient
|
from wrenn.client import WrennClient
|
||||||
from wrenn.commands import Commands
|
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)
|
commands = Commands(CAPSULE_ID, client.http)
|
||||||
|
|
||||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response(stdout="3\n"))
|
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.client import WrennClient
|
||||||
from wrenn.commands import Commands
|
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)
|
commands = Commands(CAPSULE_ID, client.http)
|
||||||
|
|
||||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
||||||
@ -1045,7 +1045,7 @@ class TestCommandPayloadWrapping:
|
|||||||
from wrenn.client import WrennClient
|
from wrenn.client import WrennClient
|
||||||
from wrenn.commands import Commands
|
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)
|
commands = Commands(CAPSULE_ID, client.http)
|
||||||
|
|
||||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
||||||
@ -1059,7 +1059,7 @@ class TestCommandPayloadWrapping:
|
|||||||
from wrenn.client import WrennClient
|
from wrenn.client import WrennClient
|
||||||
from wrenn.commands import Commands
|
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)
|
commands = Commands(CAPSULE_ID, client.http)
|
||||||
|
|
||||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
||||||
@ -1073,7 +1073,7 @@ class TestCommandPayloadWrapping:
|
|||||||
from wrenn.client import WrennClient
|
from wrenn.client import WrennClient
|
||||||
from wrenn.commands import Commands
|
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)
|
commands = Commands(CAPSULE_ID, client.http)
|
||||||
|
|
||||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
||||||
@ -1089,7 +1089,7 @@ class TestCommandPayloadWrapping:
|
|||||||
from wrenn.client import WrennClient
|
from wrenn.client import WrennClient
|
||||||
from wrenn.commands import Commands
|
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)
|
commands = Commands(CAPSULE_ID, client.http)
|
||||||
|
|
||||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
||||||
@ -1119,7 +1119,7 @@ class TestCommandPayloadWrapping:
|
|||||||
from wrenn.client import WrennClient
|
from wrenn.client import WrennClient
|
||||||
from wrenn.commands import Commands
|
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)
|
commands = Commands(CAPSULE_ID, client.http)
|
||||||
|
|
||||||
route = respx.post(EXEC_URL).respond(200, json={"pid": 42, "tag": "bg-1"})
|
route = respx.post(EXEC_URL).respond(200, json={"pid": 42, "tag": "bg-1"})
|
||||||
|
|||||||
@ -46,7 +46,7 @@ class TestCapsuleLifecycle:
|
|||||||
assert capsule_id
|
assert capsule_id
|
||||||
assert capsule.info is not None
|
assert capsule.info is not None
|
||||||
finally:
|
finally:
|
||||||
capsule.destroy()
|
capsule.destroy(wait=True)
|
||||||
|
|
||||||
info = Capsule.get_info(capsule_id)
|
info = Capsule.get_info(capsule_id)
|
||||||
assert info.status in (Status.stopped, Status.missing)
|
assert info.status in (Status.stopped, Status.missing)
|
||||||
@ -65,7 +65,7 @@ class TestCapsuleLifecycle:
|
|||||||
assert capsule.is_running()
|
assert capsule.is_running()
|
||||||
|
|
||||||
info = Capsule.get_info(capsule_id)
|
info = Capsule.get_info(capsule_id)
|
||||||
assert info.status in (Status.stopped, Status.missing)
|
assert info.status in (Status.stopping, Status.stopped, Status.missing)
|
||||||
|
|
||||||
def test_get_info(self):
|
def test_get_info(self):
|
||||||
capsule = Capsule(wait=True)
|
capsule = Capsule(wait=True)
|
||||||
@ -80,11 +80,11 @@ class TestCapsuleLifecycle:
|
|||||||
def test_pause_and_resume(self):
|
def test_pause_and_resume(self):
|
||||||
capsule = Capsule(wait=True)
|
capsule = Capsule(wait=True)
|
||||||
try:
|
try:
|
||||||
paused = capsule.pause()
|
paused = capsule.pause(wait=True)
|
||||||
assert paused.status == Status.paused
|
assert paused.status == Status.paused
|
||||||
assert not capsule.is_running()
|
assert not capsule.is_running()
|
||||||
|
|
||||||
resumed = capsule.resume()
|
resumed = capsule.resume(wait=True)
|
||||||
assert resumed.status == Status.running
|
assert resumed.status == Status.running
|
||||||
finally:
|
finally:
|
||||||
capsule.destroy()
|
capsule.destroy()
|
||||||
@ -93,7 +93,7 @@ class TestCapsuleLifecycle:
|
|||||||
capsule = Capsule(wait=True)
|
capsule = Capsule(wait=True)
|
||||||
capsule_id = capsule.capsule_id
|
capsule_id = capsule.capsule_id
|
||||||
try:
|
try:
|
||||||
Capsule.destroy(capsule_id)
|
Capsule.destroy(capsule_id, wait=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
capsule.destroy()
|
capsule.destroy()
|
||||||
raise
|
raise
|
||||||
@ -218,11 +218,14 @@ class TestCommands:
|
|||||||
def test_kill_process(self):
|
def test_kill_process(self):
|
||||||
handle = self.capsule.commands.run("sleep 30", background=True)
|
handle = self.capsule.commands.run("sleep 30", background=True)
|
||||||
self.capsule.commands.kill(handle.pid)
|
self.capsule.commands.kill(handle.pid)
|
||||||
time.sleep(0.5)
|
# Registry prune runs asynchronously after the process end event,
|
||||||
|
# so poll rather than asserting on a zero-delay list().
|
||||||
processes = self.capsule.commands.list()
|
deadline = time.monotonic() + 5
|
||||||
pids = [p.pid for p in processes]
|
while time.monotonic() < deadline:
|
||||||
assert handle.pid not in pids
|
if handle.pid not in [p.pid for p in self.capsule.commands.list()]:
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
assert handle.pid not in [p.pid for p in self.capsule.commands.list()]
|
||||||
|
|
||||||
def test_run_duration_ms(self):
|
def test_run_duration_ms(self):
|
||||||
result = self.capsule.commands.run("sleep 1")
|
result = self.capsule.commands.run("sleep 1")
|
||||||
|
|||||||
499
tests/test_integration_advanced.py
Normal file
499
tests/test_integration_advanced.py
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
"""Advanced integration tests against a live Wrenn server.
|
||||||
|
|
||||||
|
Skipped automatically when ``WRENN_API_KEY`` is not set (see conftest.py).
|
||||||
|
|
||||||
|
Covers working-directory / environment handling, long-running commands
|
||||||
|
(``apt-get``), interactive PTY sessions, streaming exec, and real ``git``
|
||||||
|
workflows including cloning ``github.com/wrennhq/wrenn``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wrenn import Capsule
|
||||||
|
from wrenn.commands import StreamExitEvent, StreamStartEvent
|
||||||
|
from wrenn.exceptions import WrennError
|
||||||
|
from wrenn.pty import PtyEventType
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
WRENN_REPO = "https://github.com/wrennhq/wrenn"
|
||||||
|
|
||||||
|
_env_loaded = False
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_env() -> None:
|
||||||
|
global _env_loaded
|
||||||
|
if _env_loaded:
|
||||||
|
return
|
||||||
|
_env_loaded = True
|
||||||
|
env_file = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
if not env_file.exists():
|
||||||
|
return
|
||||||
|
for line in env_file.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key, value = key.strip(), value.strip().strip("\"'")
|
||||||
|
if key and key not in os.environ:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Working directory & environment
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandEnvironment:
|
||||||
|
"""cwd / envs handling for foreground commands."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_cwd_changes_working_directory(self):
|
||||||
|
result = self.capsule.commands.run("pwd", cwd="/tmp")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "/tmp"
|
||||||
|
|
||||||
|
def test_default_cwd_is_home(self):
|
||||||
|
result = self.capsule.commands.run("pwd")
|
||||||
|
assert result.stdout.strip() == "/root"
|
||||||
|
|
||||||
|
def test_cwd_resolves_relative_paths(self):
|
||||||
|
self.capsule.files.make_dir("/tmp/cwd_probe/sub")
|
||||||
|
result = self.capsule.commands.run("ls", cwd="/tmp/cwd_probe")
|
||||||
|
assert "sub" in result.stdout
|
||||||
|
|
||||||
|
def test_cwd_nonexistent_raises(self):
|
||||||
|
with pytest.raises(WrennError):
|
||||||
|
self.capsule.commands.run("pwd", cwd="/no/such/dir/xyz")
|
||||||
|
|
||||||
|
def test_cwd_does_not_persist_between_calls(self):
|
||||||
|
# Each run is a fresh process — `cd` in one does not affect the next.
|
||||||
|
self.capsule.commands.run("cd /tmp")
|
||||||
|
result = self.capsule.commands.run("pwd")
|
||||||
|
assert result.stdout.strip() == "/root"
|
||||||
|
|
||||||
|
def test_single_env_var(self):
|
||||||
|
result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"})
|
||||||
|
assert result.stdout.strip() == "hi"
|
||||||
|
|
||||||
|
def test_multiple_env_vars(self):
|
||||||
|
result = self.capsule.commands.run(
|
||||||
|
"echo $A-$B-$C", envs={"A": "1", "B": "2", "C": "3"}
|
||||||
|
)
|
||||||
|
assert result.stdout.strip() == "1-2-3"
|
||||||
|
|
||||||
|
def test_env_vars_do_not_leak_between_calls(self):
|
||||||
|
self.capsule.commands.run("echo $SECRET", envs={"SECRET": "leaky"})
|
||||||
|
result = self.capsule.commands.run("echo [$SECRET]")
|
||||||
|
assert result.stdout.strip() == "[]"
|
||||||
|
|
||||||
|
def test_env_var_with_special_chars(self):
|
||||||
|
value = "a b&c|d;e"
|
||||||
|
result = self.capsule.commands.run('printf "%s" "$X"', envs={"X": value})
|
||||||
|
assert result.stdout == value
|
||||||
|
|
||||||
|
def test_base_environment_present(self):
|
||||||
|
result = self.capsule.commands.run("echo $HOME; echo $PATH")
|
||||||
|
lines = result.stdout.strip().splitlines()
|
||||||
|
assert lines[0] == "/root"
|
||||||
|
assert "/usr/bin" in lines[1]
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Long-running commands
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestLongRunningCommands:
|
||||||
|
"""apt-get installs and other slow commands."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_apt_get_install(self):
|
||||||
|
result = self.capsule.commands.run(
|
||||||
|
"apt-get update -qq && apt-get install -y -qq cowsay", timeout=300
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_apt_installed_binary_runs(self):
|
||||||
|
# Depends on test_apt_get_install having installed the package.
|
||||||
|
self.capsule.commands.run("apt-get install -y -qq cowsay", timeout=300)
|
||||||
|
result = self.capsule.commands.run("/usr/games/cowsay moo")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "moo" in result.stdout
|
||||||
|
|
||||||
|
def test_foreground_timeout_raises(self):
|
||||||
|
# A command exceeding its timeout surfaces as a server-side error.
|
||||||
|
with pytest.raises(WrennError):
|
||||||
|
self.capsule.commands.run("sleep 20", timeout=2)
|
||||||
|
|
||||||
|
def test_long_sleep_in_background_returns_immediately(self):
|
||||||
|
start = time.monotonic()
|
||||||
|
handle = self.capsule.commands.run(
|
||||||
|
"sleep 60", background=True, tag="long-sleep"
|
||||||
|
)
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
assert elapsed < 10
|
||||||
|
assert handle.pid > 0
|
||||||
|
self.capsule.commands.kill(handle.pid)
|
||||||
|
|
||||||
|
def test_slow_command_within_timeout(self):
|
||||||
|
result = self.capsule.commands.run("sleep 3 && echo done", timeout=30)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "done"
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# PTY sessions
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
def _drain_pty(term, *, max_events: int = 200) -> tuple[bytes, int | None]:
|
||||||
|
"""Collect PTY output until exit; return (output, exit_code)."""
|
||||||
|
output = b""
|
||||||
|
exit_code: int | None = None
|
||||||
|
for i, event in enumerate(term):
|
||||||
|
if event.type == PtyEventType.output and event.data:
|
||||||
|
output += event.data
|
||||||
|
elif event.type == PtyEventType.exit:
|
||||||
|
exit_code = event.exit_code
|
||||||
|
break
|
||||||
|
elif event.type == PtyEventType.error and event.fatal:
|
||||||
|
break
|
||||||
|
if i >= max_events:
|
||||||
|
break
|
||||||
|
return output, exit_code
|
||||||
|
|
||||||
|
|
||||||
|
class TestPty:
|
||||||
|
"""Interactive PTY behaviour."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_pty_runs_command_and_exits(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash") as term:
|
||||||
|
term.write(b"echo pty-result-$((6*7))\n")
|
||||||
|
term.write(b"exit\n")
|
||||||
|
output, exit_code = _drain_pty(term)
|
||||||
|
assert b"pty-result-42" in output
|
||||||
|
assert exit_code is not None
|
||||||
|
|
||||||
|
def test_pty_started_event_sets_tag_and_pid(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash") as term:
|
||||||
|
term.write(b"exit\n")
|
||||||
|
_drain_pty(term)
|
||||||
|
assert term.tag is not None
|
||||||
|
assert term.tag.startswith("pty-")
|
||||||
|
assert term.pid is not None and term.pid > 0
|
||||||
|
|
||||||
|
def test_pty_respects_cwd(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash", cwd="/tmp") as term:
|
||||||
|
term.write(b"pwd\n")
|
||||||
|
term.write(b"exit\n")
|
||||||
|
output, _ = _drain_pty(term)
|
||||||
|
assert b"/tmp" in output
|
||||||
|
|
||||||
|
def test_pty_respects_envs(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash", envs={"PTY_VAR": "xyzzy"}) as term:
|
||||||
|
term.write(b"echo marker-$PTY_VAR\n")
|
||||||
|
term.write(b"exit\n")
|
||||||
|
output, _ = _drain_pty(term)
|
||||||
|
assert b"marker-xyzzy" in output
|
||||||
|
|
||||||
|
def test_pty_resize(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash", cols=80, rows=24) as term:
|
||||||
|
term.resize(120, 40)
|
||||||
|
term.write(b"echo resized\n")
|
||||||
|
term.write(b"exit\n")
|
||||||
|
output, _ = _drain_pty(term)
|
||||||
|
assert b"resized" in output
|
||||||
|
|
||||||
|
def test_pty_explicit_command(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/echo", args=["hello-from-argv"]) as term:
|
||||||
|
output, exit_code = _drain_pty(term)
|
||||||
|
assert b"hello-from-argv" in output
|
||||||
|
|
||||||
|
def test_pty_exit_code_nonzero(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash") as term:
|
||||||
|
term.write(b"exit 3\n")
|
||||||
|
_, exit_code = _drain_pty(term)
|
||||||
|
assert exit_code == 3
|
||||||
|
|
||||||
|
def test_pty_survives_idle_ping_cycle(self):
|
||||||
|
# The server emits a keepalive `ping` (~every 30s); the SDK must
|
||||||
|
# auto-reply `pong` and the session must stay usable afterwards.
|
||||||
|
with self.capsule.pty(cmd="/bin/bash") as term:
|
||||||
|
saw_ping = False
|
||||||
|
for event in term:
|
||||||
|
if event.type == PtyEventType.ping:
|
||||||
|
saw_ping = True
|
||||||
|
break
|
||||||
|
if event.type == PtyEventType.exit:
|
||||||
|
break
|
||||||
|
if event.type == PtyEventType.error and event.fatal:
|
||||||
|
break
|
||||||
|
assert saw_ping, "no keepalive ping received"
|
||||||
|
term.write(b"echo still-alive\n")
|
||||||
|
term.write(b"exit\n")
|
||||||
|
output, _ = _drain_pty(term)
|
||||||
|
assert b"still-alive" in output
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Streaming exec
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamingExec:
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_stream_emits_start_and_exit(self):
|
||||||
|
events = list(self.capsule.commands.stream("echo streamed"))
|
||||||
|
types = [e.type for e in events]
|
||||||
|
assert "exit" in types
|
||||||
|
starts = [e for e in events if isinstance(e, StreamStartEvent)]
|
||||||
|
exits = [e for e in events if isinstance(e, StreamExitEvent)]
|
||||||
|
assert exits and exits[0].exit_code == 0
|
||||||
|
if starts:
|
||||||
|
assert starts[0].pid > 0
|
||||||
|
|
||||||
|
def test_stream_captures_stdout(self):
|
||||||
|
events = list(self.capsule.commands.stream("for i in 1 2 3; do echo n$i; done"))
|
||||||
|
out = "".join(
|
||||||
|
e.data for e in events if e.type == "stdout" and getattr(e, "data", None)
|
||||||
|
)
|
||||||
|
assert "n1" in out and "n3" in out
|
||||||
|
|
||||||
|
def test_stream_nonzero_exit(self):
|
||||||
|
events = list(self.capsule.commands.stream("exit 5"))
|
||||||
|
exits = [e for e in events if isinstance(e, StreamExitEvent)]
|
||||||
|
assert exits and exits[0].exit_code == 5
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Process connect — attach to a background process over WebSocket
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestProcessConnect:
|
||||||
|
"""commands.connect — must survive the server's abrupt WebSocket close."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_connect_streams_running_process(self):
|
||||||
|
handle = self.capsule.commands.run(
|
||||||
|
"for i in $(seq 1 5); do echo tick$i; sleep 1; done",
|
||||||
|
background=True,
|
||||||
|
tag="connect-run",
|
||||||
|
)
|
||||||
|
time.sleep(0.3)
|
||||||
|
events = list(self.capsule.commands.connect(handle.pid))
|
||||||
|
types = [e.type for e in events]
|
||||||
|
assert "exit" in types
|
||||||
|
# connect streams output from the attach point onward, so early
|
||||||
|
# ticks may be missed — assert it captured the live tail.
|
||||||
|
out = "".join(
|
||||||
|
e.data for e in events if e.type == "stdout" and getattr(e, "data", None)
|
||||||
|
)
|
||||||
|
assert "tick" in out
|
||||||
|
|
||||||
|
def test_connect_to_finished_process_does_not_raise(self):
|
||||||
|
handle = self.capsule.commands.run("echo quick", background=True)
|
||||||
|
time.sleep(2)
|
||||||
|
# Process already exited — server closes the WebSocket abruptly;
|
||||||
|
# the iterator must terminate cleanly rather than raise.
|
||||||
|
events = list(self.capsule.commands.connect(handle.pid))
|
||||||
|
assert isinstance(events, list)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Git — real workflows including cloning wrennhq/wrenn
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitClone:
|
||||||
|
"""Clone github.com/wrennhq/wrenn and operate on it."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
cls.capsule.git.clone(WRENN_REPO, "/root/wrenn", depth=1, timeout=300)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_clone_created_repo(self):
|
||||||
|
assert self.capsule.files.exists("/root/wrenn/.git")
|
||||||
|
|
||||||
|
def test_clone_checked_out_files(self):
|
||||||
|
entries = self.capsule.files.list("/root/wrenn")
|
||||||
|
names = [e.name for e in entries]
|
||||||
|
assert "README.md" in names
|
||||||
|
|
||||||
|
def test_status_of_clone_is_clean(self):
|
||||||
|
status = self.capsule.git.status(cwd="/root/wrenn")
|
||||||
|
assert status.branch == "main"
|
||||||
|
assert status.is_clean
|
||||||
|
|
||||||
|
def test_branches_lists_main(self):
|
||||||
|
branches = self.capsule.git.branches(cwd="/root/wrenn")
|
||||||
|
names = [b.name for b in branches]
|
||||||
|
assert "main" in names
|
||||||
|
assert any(b.is_current for b in branches)
|
||||||
|
|
||||||
|
def test_remote_get_origin(self):
|
||||||
|
url = self.capsule.git.remote_get("origin", cwd="/root/wrenn")
|
||||||
|
assert url is not None
|
||||||
|
assert "wrennhq/wrenn" in url
|
||||||
|
|
||||||
|
def test_git_log_has_commit(self):
|
||||||
|
result = self.capsule.commands.run("git log --oneline -1", cwd="/root/wrenn")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip()
|
||||||
|
|
||||||
|
def test_modify_add_commit(self):
|
||||||
|
marker = uuid.uuid4().hex
|
||||||
|
self.capsule.git.configure_user(
|
||||||
|
"CI Bot", "ci@example.com", cwd="/root/wrenn", scope="local"
|
||||||
|
)
|
||||||
|
self.capsule.files.write(f"/root/wrenn/sdk_probe_{marker}.txt", marker)
|
||||||
|
self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/root/wrenn")
|
||||||
|
|
||||||
|
staged = self.capsule.git.status(cwd="/root/wrenn")
|
||||||
|
assert staged.has_staged
|
||||||
|
|
||||||
|
result = self.capsule.git.commit("probe commit", cwd="/root/wrenn")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
after = self.capsule.git.status(cwd="/root/wrenn")
|
||||||
|
assert after.is_clean
|
||||||
|
assert after.ahead >= 1
|
||||||
|
|
||||||
|
def test_create_and_checkout_branch_in_clone(self):
|
||||||
|
self.capsule.git.create_branch("sdk-feature", cwd="/root/wrenn")
|
||||||
|
branches = self.capsule.git.branches(cwd="/root/wrenn")
|
||||||
|
current = [b for b in branches if b.is_current]
|
||||||
|
assert current and current[0].name == "sdk-feature"
|
||||||
|
self.capsule.git.checkout_branch("main", cwd="/root/wrenn")
|
||||||
|
|
||||||
|
def test_diff_via_commands(self):
|
||||||
|
self.capsule.files.write("/root/wrenn/README.md", "overwritten\n")
|
||||||
|
try:
|
||||||
|
result = self.capsule.commands.run("git diff --stat", cwd="/root/wrenn")
|
||||||
|
assert "README.md" in result.stdout
|
||||||
|
finally:
|
||||||
|
self.capsule.git.restore(["README.md"], worktree=True, cwd="/root/wrenn")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitErrors:
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_clone_nonexistent_repo_raises(self):
|
||||||
|
from wrenn._git import GitError
|
||||||
|
|
||||||
|
with pytest.raises(GitError):
|
||||||
|
self.capsule.git.clone(
|
||||||
|
"https://github.com/wrennhq/this-repo-does-not-exist-xyz",
|
||||||
|
"/root/missing",
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_status_outside_repo_raises(self):
|
||||||
|
from wrenn._git import GitError
|
||||||
|
|
||||||
|
with pytest.raises(GitError):
|
||||||
|
self.capsule.git.status(cwd="/tmp")
|
||||||
|
|
||||||
|
def test_clone_with_branch(self):
|
||||||
|
self.capsule.git.clone(
|
||||||
|
WRENN_REPO, "/root/wrenn-main", branch="main", depth=1, timeout=300
|
||||||
|
)
|
||||||
|
status = self.capsule.git.status(cwd="/root/wrenn-main")
|
||||||
|
assert status.branch == "main"
|
||||||
93
uv.lock
generated
93
uv.lock
generated
@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "charset-normalizer"
|
name = "charset-normalizer"
|
||||||
version = "3.4.7"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "dnspython"
|
name = "dnspython"
|
||||||
version = "2.8.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "genson"
|
name = "genson"
|
||||||
version = "1.3.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.11"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "nr-date"
|
name = "nr-date"
|
||||||
version = "2.1.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.12.5"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pytokens"
|
name = "pytokens"
|
||||||
version = "0.4.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "watchdog"
|
name = "watchdog"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@ -1032,7 +1121,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wrenn"
|
name = "wrenn"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "email-validator" },
|
{ name = "email-validator" },
|
||||||
@ -1045,6 +1134,7 @@ dependencies = [
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "datamodel-code-generator", extra = ["ruff"] },
|
{ name = "datamodel-code-generator", extra = ["ruff"] },
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
|
{ name = "pre-commit" },
|
||||||
{ name = "pydoc-markdown" },
|
{ name = "pydoc-markdown" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
@ -1064,6 +1154,7 @@ requires-dist = [
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" },
|
{ name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" },
|
||||||
{ name = "mypy", specifier = ">=1.20.0" },
|
{ name = "mypy", specifier = ">=1.20.0" },
|
||||||
|
{ name = "pre-commit", specifier = ">=4.6.0" },
|
||||||
{ name = "pydoc-markdown", specifier = ">=4.8.2" },
|
{ name = "pydoc-markdown", specifier = ">=4.8.2" },
|
||||||
{ name = "pytest", specifier = ">=9.0.3" },
|
{ name = "pytest", specifier = ">=9.0.3" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user