Compare commits
3 Commits
feat/modul
...
bugfix/tim
| Author | SHA1 | Date | |
|---|---|---|---|
| 06b4a8cbcb | |||
| 04e5dc652f | |||
| 4a7db8e204 |
5
.gitignore
vendored
5
.gitignore
vendored
@ -176,5 +176,8 @@ cython_debug/
|
|||||||
|
|
||||||
CODE_EXECUTION.md
|
CODE_EXECUTION.md
|
||||||
|
|
||||||
.claude/
|
|
||||||
.opencode/
|
.opencode/
|
||||||
|
# AI
|
||||||
|
.code-review-graph/
|
||||||
|
.claude
|
||||||
|
.mcp.json
|
||||||
|
|||||||
25
.pre-commit-config.yaml
Normal file
25
.pre-commit-config.yaml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.15.10
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
- id: ruff-format
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.20.0
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
additional_dependencies:
|
||||||
|
- pydantic>=2.12.5
|
||||||
|
- httpx>=0.28.1
|
||||||
|
- httpx-ws>=0.9.0
|
||||||
|
- email-validator>=2.3.0
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: unit-tests
|
||||||
|
name: unit tests
|
||||||
|
entry: uv run pytest -m "not integration" -x -q
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
@ -1,5 +1,5 @@
|
|||||||
when:
|
when:
|
||||||
event: push
|
event: pull_request
|
||||||
branch:
|
branch:
|
||||||
- main
|
- main
|
||||||
- dev
|
- dev
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "wrenn"
|
name = "wrenn"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
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",
|
||||||
|
|||||||
@ -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 logging
|
||||||
|
import builtins
|
||||||
import time
|
import time
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@ -102,6 +104,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,
|
||||||
@ -240,8 +243,10 @@ class AsyncCapsule:
|
|||||||
if info.status == Status.running:
|
if info.status == Status.running:
|
||||||
self._info = info
|
self._info = info
|
||||||
return
|
return
|
||||||
if info.status in (Status.error, Status.stopped, Status.paused):
|
if info.status in (Status.error, Status.stopped):
|
||||||
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
||||||
|
if info.status == Status.paused:
|
||||||
|
info = await self._client.capsules.resume(self._id)
|
||||||
await asyncio.sleep(interval)
|
await asyncio.sleep(interval)
|
||||||
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
||||||
|
|
||||||
@ -284,7 +289,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 +321,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 +340,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 +392,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 logging
|
||||||
|
import builtins
|
||||||
import time
|
import time
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
@ -94,14 +96,16 @@ 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
|
||||||
|
if self._id is None:
|
||||||
|
self._client.close()
|
||||||
|
raise RuntimeError("API returned a capsule without an ID")
|
||||||
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)
|
||||||
|
try:
|
||||||
self._info = self._client.capsules.create(
|
self._info = self._client.capsules.create(
|
||||||
template=template,
|
template=template,
|
||||||
vcpus=vcpus,
|
vcpus=vcpus,
|
||||||
@ -109,6 +113,11 @@ class Capsule:
|
|||||||
timeout_sec=timeout,
|
timeout_sec=timeout,
|
||||||
)
|
)
|
||||||
self._id = self._info.id
|
self._id = self._info.id
|
||||||
|
if self._id is None:
|
||||||
|
raise RuntimeError("API returned a capsule without an ID")
|
||||||
|
except Exception:
|
||||||
|
self._client.close()
|
||||||
|
raise
|
||||||
|
|
||||||
self.commands = Commands(self._id, self._client.http)
|
self.commands = Commands(self._id, self._client.http)
|
||||||
self.files = Files(self._id, self._client.http)
|
self.files = Files(self._id, self._client.http)
|
||||||
@ -316,8 +325,10 @@ class Capsule:
|
|||||||
if info.status == Status.running:
|
if info.status == Status.running:
|
||||||
self._info = info
|
self._info = info
|
||||||
return
|
return
|
||||||
if info.status in (Status.error, Status.stopped, Status.paused):
|
if info.status in (Status.error, Status.stopped):
|
||||||
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
||||||
|
if info.status == Status.paused:
|
||||||
|
info = self._client.capsules.resume(self._id)
|
||||||
time.sleep(interval)
|
time.sleep(interval)
|
||||||
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
||||||
|
|
||||||
@ -360,7 +371,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 +402,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 +421,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 +473,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)
|
||||||
@ -108,7 +111,7 @@ class CapsulesResource:
|
|||||||
Raises:
|
Raises:
|
||||||
WrennNotFoundError: If no capsule with the given ID exists.
|
WrennNotFoundError: If no capsule with the given ID exists.
|
||||||
"""
|
"""
|
||||||
resp = self._http.post(f"/v1/capsules/{id}/pause")
|
resp = self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT)
|
||||||
return CapsuleModel.model_validate(handle_response(resp))
|
return CapsuleModel.model_validate(handle_response(resp))
|
||||||
|
|
||||||
def resume(self, id: str) -> CapsuleModel:
|
def resume(self, id: str) -> CapsuleModel:
|
||||||
@ -224,7 +227,7 @@ class AsyncCapsulesResource:
|
|||||||
Raises:
|
Raises:
|
||||||
WrennNotFoundError: If no capsule with the given ID exists.
|
WrennNotFoundError: If no capsule with the given ID exists.
|
||||||
"""
|
"""
|
||||||
resp = await self._http.post(f"/v1/capsules/{id}/pause")
|
resp = await self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT)
|
||||||
return CapsuleModel.model_validate(handle_response(resp))
|
return CapsuleModel.model_validate(handle_response(resp))
|
||||||
|
|
||||||
async def resume(self, id: str) -> CapsuleModel:
|
async def resume(self, id: str) -> CapsuleModel:
|
||||||
@ -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:
|
||||||
|
if exc.response.status_code < 500:
|
||||||
raise
|
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:
|
||||||
|
if exc.response.status_code < 500:
|
||||||
raise
|
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,10 +1,11 @@
|
|||||||
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
|
||||||
@ -197,8 +198,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 +227,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 +263,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()
|
||||||
@ -263,7 +274,9 @@ class Commands:
|
|||||||
except httpx_ws.WebSocketDisconnect:
|
except httpx_ws.WebSocketDisconnect:
|
||||||
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 +293,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:
|
||||||
@ -374,10 +387,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 +416,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 +454,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()
|
||||||
@ -445,7 +466,7 @@ class AsyncCommands:
|
|||||||
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 +484,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:
|
||||||
|
|||||||
@ -110,13 +110,18 @@ _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:
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
resp.raise_for_status()
|
raise WrennInternalError(
|
||||||
raise
|
code="internal_error",
|
||||||
|
message=resp.text or f"HTTP {resp.status_code}",
|
||||||
|
status_code=resp.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
err = body.get("error", {})
|
err = body.get("error", {})
|
||||||
code = err.get("code", "internal_error")
|
code = err.get("code", "internal_error")
|
||||||
@ -129,7 +134,7 @@ def handle_response(resp: httpx.Response) -> dict | list:
|
|||||||
code=code,
|
code=code,
|
||||||
message=message,
|
message=message,
|
||||||
status_code=resp.status_code,
|
status_code=resp.status_code,
|
||||||
capsule_ids=body.get("sandbox_ids", []),
|
capsule_ids=body.get("capsule_ids") or body.get("sandbox_ids", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
raise exc_cls(
|
raise exc_cls(
|
||||||
@ -138,6 +143,10 @@ def handle_response(resp: httpx.Response) -> dict | list:
|
|||||||
status_code=resp.status_code,
|
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 {}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@ -46,7 +46,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 +65,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.
|
||||||
@ -179,7 +179,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 +243,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 +262,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.
|
||||||
@ -377,7 +377,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.
|
||||||
|
|||||||
@ -153,7 +153,8 @@ class PtySession:
|
|||||||
if event.pid is not None:
|
if event.pid is not None:
|
||||||
self._pid = event.pid
|
self._pid = event.pid
|
||||||
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
|
||||||
@ -281,7 +282,8 @@ class AsyncPtySession:
|
|||||||
if event.pid is not None:
|
if event.pid is not None:
|
||||||
self._pid = event.pid
|
self._pid = event.pid
|
||||||
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,104 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from collections.abc import AsyncGenerator, Generator
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
|
|
||||||
from wrenn.async_capsule import AsyncCapsule
|
|
||||||
from wrenn.capsule import Capsule
|
|
||||||
from wrenn.client import AsyncWrennClient, WrennClient
|
|
||||||
|
|
||||||
WRENN_API_KEY = os.environ.get("WRENN_API_KEY")
|
|
||||||
WRENN_BASE_URL = os.environ.get("WRENN_BASE_URL", "http://localhost:8080")
|
|
||||||
|
|
||||||
_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
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _load_env():
|
|
||||||
_ensure_env()
|
|
||||||
|
|
||||||
|
|
||||||
def _has_auth() -> bool:
|
|
||||||
return bool(WRENN_API_KEY)
|
|
||||||
|
|
||||||
|
|
||||||
requires_auth = pytest.mark.skipif(
|
|
||||||
not _has_auth(),
|
|
||||||
reason="Set WRENN_API_KEY to run integration tests",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client() -> Generator[WrennClient, None, None]:
|
|
||||||
with WrennClient(
|
|
||||||
api_key=WRENN_API_KEY,
|
|
||||||
base_url=WRENN_BASE_URL,
|
|
||||||
) as c:
|
|
||||||
yield c
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def async_client() -> AsyncGenerator[AsyncWrennClient, None]:
|
|
||||||
async with AsyncWrennClient(api_key=WRENN_API_KEY, base_url=WRENN_BASE_URL) as c:
|
|
||||||
yield c
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def async_minimal_capsule() -> AsyncGenerator[AsyncCapsule, None]:
|
|
||||||
"""Provides a ready-to-use minimal capsule and cleans it up afterward."""
|
|
||||||
async with await AsyncCapsule.create(
|
|
||||||
template="minimal",
|
|
||||||
timeout=120,
|
|
||||||
wait=True,
|
|
||||||
api_key=WRENN_API_KEY,
|
|
||||||
base_url=WRENN_BASE_URL,
|
|
||||||
) as cap:
|
|
||||||
yield cap
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def async_python_capsule() -> AsyncGenerator[AsyncCapsule, None]:
|
|
||||||
"""Provides a ready-to-use Python interpreter capsule."""
|
|
||||||
async with await AsyncCapsule.create(
|
|
||||||
template="python-interpreter-v0-beta",
|
|
||||||
timeout=120,
|
|
||||||
wait=True,
|
|
||||||
api_key=WRENN_API_KEY,
|
|
||||||
base_url=WRENN_BASE_URL,
|
|
||||||
) as cap:
|
|
||||||
yield cap
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def minimal_capsule() -> Generator[Capsule, None, None]:
|
|
||||||
"""Provides a ready-to-use minimal capsule and cleans it up afterward."""
|
|
||||||
with Capsule(
|
|
||||||
template="minimal",
|
|
||||||
timeout=120,
|
|
||||||
wait=True,
|
|
||||||
api_key=WRENN_API_KEY,
|
|
||||||
base_url=WRENN_BASE_URL,
|
|
||||||
) as cap:
|
|
||||||
yield cap
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from wrenn import Capsule, CommandResult
|
|
||||||
from wrenn.commands import CommandHandle, ProcessInfo
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
|
||||||
|
|
||||||
|
|
||||||
class TestCommands:
|
|
||||||
"""Shared capsule for command execution tests."""
|
|
||||||
|
|
||||||
capsule: Capsule
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setup_class(cls):
|
|
||||||
cls.capsule = Capsule(wait=True)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def teardown_class(cls):
|
|
||||||
try:
|
|
||||||
cls.capsule.destroy()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_run_foreground(self):
|
|
||||||
result = self.capsule.commands.run("echo hello")
|
|
||||||
assert isinstance(result, CommandResult)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "hello" in result.stdout
|
|
||||||
|
|
||||||
def test_run_stderr(self):
|
|
||||||
result = self.capsule.commands.run("echo error >&2")
|
|
||||||
assert "error" in result.stderr
|
|
||||||
|
|
||||||
def test_run_exit_code(self):
|
|
||||||
result = self.capsule.commands.run("exit 42")
|
|
||||||
assert result.exit_code == 42
|
|
||||||
|
|
||||||
def test_run_with_envs(self):
|
|
||||||
result = self.capsule.commands.run("export MY_VAR=test_value && echo $MY_VAR")
|
|
||||||
assert "test_value" in result.stdout
|
|
||||||
|
|
||||||
def test_run_with_cwd(self):
|
|
||||||
result = self.capsule.commands.run("cd /tmp && pwd")
|
|
||||||
assert result.stdout.strip() == "/tmp"
|
|
||||||
|
|
||||||
def test_run_multiline_output(self):
|
|
||||||
result = self.capsule.commands.run("echo -e 'line1\\nline2\\nline3'")
|
|
||||||
assert result.exit_code == 0
|
|
||||||
lines = result.stdout.strip().splitlines()
|
|
||||||
assert len(lines) == 3
|
|
||||||
|
|
||||||
def test_run_background(self):
|
|
||||||
handle = self.capsule.commands.run("sleep 30", background=True, tag="bg-test")
|
|
||||||
assert isinstance(handle, CommandHandle)
|
|
||||||
assert handle.pid > 0
|
|
||||||
assert handle.tag == "bg-test"
|
|
||||||
assert handle.capsule_id == self.capsule.capsule_id
|
|
||||||
|
|
||||||
self.capsule.commands.kill(handle.pid)
|
|
||||||
|
|
||||||
def test_list_processes(self):
|
|
||||||
handle = self.capsule.commands.run("sleep 30", background=True, tag="list-test")
|
|
||||||
try:
|
|
||||||
time.sleep(0.5)
|
|
||||||
processes = self.capsule.commands.list()
|
|
||||||
assert isinstance(processes, list)
|
|
||||||
pids = [p.pid for p in processes]
|
|
||||||
assert handle.pid in pids
|
|
||||||
|
|
||||||
proc = next(p for p in processes if p.pid == handle.pid)
|
|
||||||
assert isinstance(proc, ProcessInfo)
|
|
||||||
finally:
|
|
||||||
self.capsule.commands.kill(handle.pid)
|
|
||||||
|
|
||||||
def test_kill_process(self):
|
|
||||||
handle = self.capsule.commands.run("sleep 30", background=True)
|
|
||||||
self.capsule.commands.kill(handle.pid)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
processes = self.capsule.commands.list()
|
|
||||||
pids = [p.pid for p in processes]
|
|
||||||
assert handle.pid not in pids
|
|
||||||
|
|
||||||
def test_run_duration_ms(self):
|
|
||||||
result = self.capsule.commands.run("sleep 1")
|
|
||||||
assert result.duration_ms is None or result.duration_ms >= 900
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from wrenn import Capsule
|
|
||||||
from wrenn.models import FileEntry
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
|
||||||
|
|
||||||
|
|
||||||
class TestFiles:
|
|
||||||
"""Shared capsule for filesystem tests."""
|
|
||||||
|
|
||||||
capsule: Capsule
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setup_class(cls):
|
|
||||||
cls.capsule = Capsule(wait=True)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def teardown_class(cls):
|
|
||||||
try:
|
|
||||||
cls.capsule.destroy()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_write_and_read(self):
|
|
||||||
self.capsule.files.write("/tmp/test.txt", "hello world")
|
|
||||||
content = self.capsule.files.read("/tmp/test.txt")
|
|
||||||
assert content == "hello world"
|
|
||||||
|
|
||||||
def test_write_and_read_bytes(self):
|
|
||||||
data = b"\x00\x01\x02\xff"
|
|
||||||
self.capsule.files.write("/tmp/test.bin", data)
|
|
||||||
result = self.capsule.files.read_bytes("/tmp/test.bin")
|
|
||||||
assert result == data
|
|
||||||
|
|
||||||
def test_list_directory(self):
|
|
||||||
self.capsule.files.write("/tmp/listdir/a.txt", "a")
|
|
||||||
self.capsule.files.write("/tmp/listdir/b.txt", "b")
|
|
||||||
entries = self.capsule.files.list("/tmp/listdir")
|
|
||||||
assert isinstance(entries, list)
|
|
||||||
names = [e.name for e in entries]
|
|
||||||
assert "a.txt" in names
|
|
||||||
assert "b.txt" in names
|
|
||||||
|
|
||||||
def test_exists(self):
|
|
||||||
self.capsule.files.write("/tmp/exists_test.txt", "x")
|
|
||||||
assert self.capsule.files.exists("/tmp/exists_test.txt")
|
|
||||||
assert not self.capsule.files.exists("/tmp/does_not_exist_xyz.txt")
|
|
||||||
|
|
||||||
def test_make_dir(self):
|
|
||||||
entry = self.capsule.files.make_dir("/tmp/newdir")
|
|
||||||
assert isinstance(entry, FileEntry)
|
|
||||||
assert self.capsule.files.exists("/tmp/newdir")
|
|
||||||
|
|
||||||
def test_make_dir_idempotent(self):
|
|
||||||
self.capsule.files.make_dir("/tmp/idempotent_dir")
|
|
||||||
entry = self.capsule.files.make_dir("/tmp/idempotent_dir")
|
|
||||||
assert isinstance(entry, FileEntry)
|
|
||||||
|
|
||||||
def test_remove_file(self):
|
|
||||||
self.capsule.files.write("/tmp/to_remove.txt", "delete me")
|
|
||||||
assert self.capsule.files.exists("/tmp/to_remove.txt")
|
|
||||||
self.capsule.files.remove("/tmp/to_remove.txt")
|
|
||||||
assert not self.capsule.files.exists("/tmp/to_remove.txt")
|
|
||||||
|
|
||||||
def test_remove_directory(self):
|
|
||||||
self.capsule.files.make_dir("/tmp/dir_to_remove")
|
|
||||||
self.capsule.files.write("/tmp/dir_to_remove/child.txt", "data")
|
|
||||||
self.capsule.files.remove("/tmp/dir_to_remove")
|
|
||||||
assert not self.capsule.files.exists("/tmp/dir_to_remove")
|
|
||||||
|
|
||||||
def test_write_creates_parent_dirs(self):
|
|
||||||
self.capsule.files.write("/tmp/deep/nested/dir/file.txt", "nested")
|
|
||||||
content = self.capsule.files.read("/tmp/deep/nested/dir/file.txt")
|
|
||||||
assert content == "nested"
|
|
||||||
|
|
||||||
def test_list_with_depth(self):
|
|
||||||
self.capsule.files.write("/tmp/depth_test/a/b.txt", "deep")
|
|
||||||
entries_shallow = self.capsule.files.list("/tmp/depth_test", depth=1)
|
|
||||||
entries_deep = self.capsule.files.list("/tmp/depth_test", depth=2)
|
|
||||||
assert len(entries_deep) >= len(entries_shallow)
|
|
||||||
|
|
||||||
def test_overwrite_file(self):
|
|
||||||
self.capsule.files.write("/tmp/overwrite.txt", "original")
|
|
||||||
self.capsule.files.write("/tmp/overwrite.txt", "updated")
|
|
||||||
content = self.capsule.files.read("/tmp/overwrite.txt")
|
|
||||||
assert content == "updated"
|
|
||||||
|
|
||||||
def test_upload_and_download_stream(self):
|
|
||||||
chunks = [b"chunk1", b"chunk2", b"chunk3"]
|
|
||||||
self.capsule.files.upload_stream("/tmp/streamed.bin", iter(chunks))
|
|
||||||
downloaded = b"".join(self.capsule.files.download_stream("/tmp/streamed.bin"))
|
|
||||||
assert downloaded == b"chunk1chunk2chunk3"
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from wrenn import Capsule
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
|
||||||
|
|
||||||
|
|
||||||
class TestGit:
|
|
||||||
"""Shared capsule for git operation tests.
|
|
||||||
|
|
||||||
Initializes a repo at /root (default cwd) since the exec API
|
|
||||||
does not support the cwd parameter.
|
|
||||||
"""
|
|
||||||
|
|
||||||
capsule: Capsule
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setup_class(cls):
|
|
||||||
cls.capsule = Capsule(wait=True)
|
|
||||||
cls.capsule.git.init(".", initial_branch="main")
|
|
||||||
cls.capsule.git.configure_user("Test User", "test@example.com")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def teardown_class(cls):
|
|
||||||
try:
|
|
||||||
cls.capsule.destroy()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_init_created_repo(self):
|
|
||||||
assert self.capsule.files.exists("/root/.git")
|
|
||||||
|
|
||||||
def test_status_clean(self):
|
|
||||||
status = self.capsule.git.status()
|
|
||||||
assert status.branch == "main"
|
|
||||||
|
|
||||||
def test_add_and_commit(self):
|
|
||||||
self.capsule.files.write("/root/hello.txt", "hello git")
|
|
||||||
self.capsule.git.add(all=True)
|
|
||||||
result = self.capsule.git.commit("initial commit")
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
def test_status_after_commit(self):
|
|
||||||
status = self.capsule.git.status()
|
|
||||||
assert status.is_clean
|
|
||||||
|
|
||||||
def test_status_with_changes(self):
|
|
||||||
self.capsule.files.write("/root/dirty.txt", "uncommitted")
|
|
||||||
try:
|
|
||||||
status = self.capsule.git.status()
|
|
||||||
assert not status.is_clean
|
|
||||||
paths = [f.path for f in status.files]
|
|
||||||
assert "dirty.txt" in paths
|
|
||||||
finally:
|
|
||||||
self.capsule.files.remove("/root/dirty.txt")
|
|
||||||
|
|
||||||
def test_branches(self):
|
|
||||||
branches = self.capsule.git.branches()
|
|
||||||
assert len(branches) >= 1
|
|
||||||
names = [b.name for b in branches]
|
|
||||||
assert "main" in names
|
|
||||||
current = [b for b in branches if b.is_current]
|
|
||||||
assert len(current) == 1
|
|
||||||
|
|
||||||
def test_create_and_checkout_branch(self):
|
|
||||||
self.capsule.git.create_branch("feature-1")
|
|
||||||
branches = self.capsule.git.branches()
|
|
||||||
names = [b.name for b in branches]
|
|
||||||
assert "feature-1" in names
|
|
||||||
|
|
||||||
current = [b for b in branches if b.is_current]
|
|
||||||
assert current[0].name == "feature-1"
|
|
||||||
|
|
||||||
self.capsule.git.checkout_branch("main")
|
|
||||||
|
|
||||||
def test_delete_branch(self):
|
|
||||||
self.capsule.git.create_branch("to-delete")
|
|
||||||
self.capsule.git.checkout_branch("main")
|
|
||||||
self.capsule.git.delete_branch("to-delete")
|
|
||||||
|
|
||||||
branches = self.capsule.git.branches()
|
|
||||||
names = [b.name for b in branches]
|
|
||||||
assert "to-delete" not in names
|
|
||||||
|
|
||||||
def test_set_and_get_config(self):
|
|
||||||
self.capsule.git.set_config("test.key", "test-value")
|
|
||||||
value = self.capsule.git.get_config("test.key")
|
|
||||||
assert value == "test-value"
|
|
||||||
|
|
||||||
def test_get_config_missing_returns_none(self):
|
|
||||||
value = self.capsule.git.get_config("nonexistent.key")
|
|
||||||
assert value is None
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from wrenn import Capsule
|
|
||||||
from wrenn.models import Capsule as CapsuleModel, Status
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
|
||||||
|
|
||||||
|
|
||||||
class TestCapsuleLifecycle:
|
|
||||||
"""Each test manages its own capsule to test create/destroy paths."""
|
|
||||||
|
|
||||||
def test_create_and_destroy(self):
|
|
||||||
capsule = Capsule()
|
|
||||||
capsule_id = capsule.capsule_id
|
|
||||||
try:
|
|
||||||
assert capsule_id
|
|
||||||
assert capsule.info is not None
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
|
|
||||||
info = Capsule.get_info(capsule_id)
|
|
||||||
assert info.status in (Status.stopped, Status.missing)
|
|
||||||
|
|
||||||
def test_create_with_wait(self):
|
|
||||||
capsule = Capsule(wait=True)
|
|
||||||
try:
|
|
||||||
assert capsule.info is not None
|
|
||||||
assert capsule.info.status == Status.running
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
|
|
||||||
def test_context_manager_destroys(self):
|
|
||||||
with Capsule(wait=True) as capsule:
|
|
||||||
capsule_id = capsule.capsule_id
|
|
||||||
assert capsule.is_running()
|
|
||||||
|
|
||||||
info = Capsule.get_info(capsule_id)
|
|
||||||
assert info.status in (Status.stopped, Status.missing)
|
|
||||||
|
|
||||||
def test_get_info(self):
|
|
||||||
capsule = Capsule(wait=True)
|
|
||||||
try:
|
|
||||||
info = capsule.get_info()
|
|
||||||
assert isinstance(info, CapsuleModel)
|
|
||||||
assert info.id == capsule.capsule_id
|
|
||||||
assert info.status == Status.running
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
|
|
||||||
def test_pause_and_resume(self):
|
|
||||||
capsule = Capsule(wait=True)
|
|
||||||
try:
|
|
||||||
paused = capsule.pause()
|
|
||||||
assert paused.status == Status.paused
|
|
||||||
assert not capsule.is_running()
|
|
||||||
|
|
||||||
resumed = capsule.resume()
|
|
||||||
assert resumed.status == Status.running
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
|
|
||||||
def test_static_destroy(self):
|
|
||||||
capsule = Capsule(wait=True)
|
|
||||||
capsule_id = capsule.capsule_id
|
|
||||||
try:
|
|
||||||
Capsule.destroy(capsule_id)
|
|
||||||
except Exception:
|
|
||||||
capsule.destroy()
|
|
||||||
raise
|
|
||||||
|
|
||||||
info = Capsule.get_info(capsule_id)
|
|
||||||
assert info.status in (Status.stopped, Status.missing)
|
|
||||||
|
|
||||||
def test_connect_to_existing(self):
|
|
||||||
capsule = Capsule(wait=True)
|
|
||||||
try:
|
|
||||||
connected = Capsule.connect(capsule.capsule_id)
|
|
||||||
assert connected.capsule_id == capsule.capsule_id
|
|
||||||
assert connected.info is not None
|
|
||||||
assert connected.info.status == Status.running
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
|
|
||||||
def test_connect_resumes_paused(self):
|
|
||||||
capsule = Capsule(wait=True)
|
|
||||||
try:
|
|
||||||
capsule.pause()
|
|
||||||
connected = Capsule.connect(capsule.capsule_id)
|
|
||||||
assert connected.info is not None
|
|
||||||
assert connected.info.status == Status.running
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
|
|
||||||
def test_list_capsules(self):
|
|
||||||
capsule = Capsule(wait=True)
|
|
||||||
try:
|
|
||||||
capsules = Capsule.list()
|
|
||||||
assert isinstance(capsules, list)
|
|
||||||
ids = [c.id for c in capsules]
|
|
||||||
assert capsule.capsule_id in ids
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
|
|
||||||
def test_wait_ready(self):
|
|
||||||
capsule = Capsule()
|
|
||||||
try:
|
|
||||||
capsule.wait_ready(timeout=60)
|
|
||||||
assert capsule.is_running()
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
|
|
||||||
def test_ping(self):
|
|
||||||
capsule = Capsule(wait=True)
|
|
||||||
try:
|
|
||||||
capsule.ping()
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
@ -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()
|
||||||
@ -461,10 +463,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:
|
||||||
|
|||||||
405
tests/test_integration.py
Normal file
405
tests/test_integration.py
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wrenn import Capsule, CommandResult
|
||||||
|
from wrenn.commands import CommandHandle, ProcessInfo
|
||||||
|
from wrenn.models import Capsule as CapsuleModel, FileEntry, Status
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_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
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapsuleLifecycle:
|
||||||
|
"""Each test manages its own capsule to test create/destroy paths."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
_ensure_env()
|
||||||
|
|
||||||
|
def test_create_and_destroy(self):
|
||||||
|
capsule = Capsule()
|
||||||
|
capsule_id = capsule.capsule_id
|
||||||
|
try:
|
||||||
|
assert capsule_id
|
||||||
|
assert capsule.info is not None
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
info = Capsule.get_info(capsule_id)
|
||||||
|
assert info.status in (Status.stopped, Status.missing)
|
||||||
|
|
||||||
|
def test_create_with_wait(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
assert capsule.info is not None
|
||||||
|
assert capsule.info.status == Status.running
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_context_manager_destroys(self):
|
||||||
|
with Capsule(wait=True) as capsule:
|
||||||
|
capsule_id = capsule.capsule_id
|
||||||
|
assert capsule.is_running()
|
||||||
|
|
||||||
|
info = Capsule.get_info(capsule_id)
|
||||||
|
assert info.status in (Status.stopped, Status.missing)
|
||||||
|
|
||||||
|
def test_get_info(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
info = capsule.get_info()
|
||||||
|
assert isinstance(info, CapsuleModel)
|
||||||
|
assert info.id == capsule.capsule_id
|
||||||
|
assert info.status == Status.running
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_pause_and_resume(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
paused = capsule.pause()
|
||||||
|
assert paused.status == Status.paused
|
||||||
|
assert not capsule.is_running()
|
||||||
|
|
||||||
|
resumed = capsule.resume()
|
||||||
|
assert resumed.status == Status.running
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_static_destroy(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
capsule_id = capsule.capsule_id
|
||||||
|
try:
|
||||||
|
Capsule.destroy(capsule_id)
|
||||||
|
except Exception:
|
||||||
|
capsule.destroy()
|
||||||
|
raise
|
||||||
|
|
||||||
|
info = Capsule.get_info(capsule_id)
|
||||||
|
assert info.status in (Status.stopped, Status.missing)
|
||||||
|
|
||||||
|
def test_connect_to_existing(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
connected = Capsule.connect(capsule.capsule_id)
|
||||||
|
assert connected.capsule_id == capsule.capsule_id
|
||||||
|
assert connected.info is not None
|
||||||
|
assert connected.info.status == Status.running
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_connect_resumes_paused(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
capsule.pause()
|
||||||
|
connected = Capsule.connect(capsule.capsule_id)
|
||||||
|
assert connected.info is not None
|
||||||
|
assert connected.info.status == Status.running
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_list_capsules(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
capsules = Capsule.list()
|
||||||
|
assert isinstance(capsules, list)
|
||||||
|
ids = [c.id for c in capsules]
|
||||||
|
assert capsule.capsule_id in ids
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_wait_ready(self):
|
||||||
|
capsule = Capsule()
|
||||||
|
try:
|
||||||
|
capsule.wait_ready(timeout=60)
|
||||||
|
assert capsule.is_running()
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_ping(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
capsule.ping()
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommands:
|
||||||
|
"""Shared capsule for command execution tests."""
|
||||||
|
|
||||||
|
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_run_foreground(self):
|
||||||
|
result = self.capsule.commands.run("echo hello")
|
||||||
|
assert isinstance(result, CommandResult)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "hello" in result.stdout
|
||||||
|
|
||||||
|
def test_run_stderr(self):
|
||||||
|
result = self.capsule.commands.run("echo error >&2")
|
||||||
|
assert "error" in result.stderr
|
||||||
|
|
||||||
|
def test_run_exit_code(self):
|
||||||
|
result = self.capsule.commands.run("exit 42")
|
||||||
|
assert result.exit_code == 42
|
||||||
|
|
||||||
|
def test_run_with_envs(self):
|
||||||
|
result = self.capsule.commands.run("export MY_VAR=test_value && echo $MY_VAR")
|
||||||
|
assert "test_value" in result.stdout
|
||||||
|
|
||||||
|
def test_run_with_cwd(self):
|
||||||
|
result = self.capsule.commands.run("cd /tmp && pwd")
|
||||||
|
assert result.stdout.strip() == "/tmp"
|
||||||
|
|
||||||
|
def test_run_multiline_output(self):
|
||||||
|
result = self.capsule.commands.run("echo -e 'line1\\nline2\\nline3'")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
lines = result.stdout.strip().splitlines()
|
||||||
|
assert len(lines) == 3
|
||||||
|
|
||||||
|
def test_run_background(self):
|
||||||
|
handle = self.capsule.commands.run("sleep 30", background=True, tag="bg-test")
|
||||||
|
assert isinstance(handle, CommandHandle)
|
||||||
|
assert handle.pid > 0
|
||||||
|
assert handle.tag == "bg-test"
|
||||||
|
assert handle.capsule_id == self.capsule.capsule_id
|
||||||
|
|
||||||
|
self.capsule.commands.kill(handle.pid)
|
||||||
|
|
||||||
|
def test_list_processes(self):
|
||||||
|
handle = self.capsule.commands.run("sleep 30", background=True, tag="list-test")
|
||||||
|
try:
|
||||||
|
time.sleep(0.5)
|
||||||
|
processes = self.capsule.commands.list()
|
||||||
|
assert isinstance(processes, list)
|
||||||
|
pids = [p.pid for p in processes]
|
||||||
|
assert handle.pid in pids
|
||||||
|
|
||||||
|
proc = next(p for p in processes if p.pid == handle.pid)
|
||||||
|
assert isinstance(proc, ProcessInfo)
|
||||||
|
finally:
|
||||||
|
self.capsule.commands.kill(handle.pid)
|
||||||
|
|
||||||
|
def test_kill_process(self):
|
||||||
|
handle = self.capsule.commands.run("sleep 30", background=True)
|
||||||
|
self.capsule.commands.kill(handle.pid)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
processes = self.capsule.commands.list()
|
||||||
|
pids = [p.pid for p in processes]
|
||||||
|
assert handle.pid not in pids
|
||||||
|
|
||||||
|
def test_run_duration_ms(self):
|
||||||
|
result = self.capsule.commands.run("sleep 1")
|
||||||
|
assert result.duration_ms is None or result.duration_ms >= 900
|
||||||
|
|
||||||
|
|
||||||
|
class TestFiles:
|
||||||
|
"""Shared capsule for filesystem tests."""
|
||||||
|
|
||||||
|
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_write_and_read(self):
|
||||||
|
self.capsule.files.write("/tmp/test.txt", "hello world")
|
||||||
|
content = self.capsule.files.read("/tmp/test.txt")
|
||||||
|
assert content == "hello world"
|
||||||
|
|
||||||
|
def test_write_and_read_bytes(self):
|
||||||
|
data = b"\x00\x01\x02\xff"
|
||||||
|
self.capsule.files.write("/tmp/test.bin", data)
|
||||||
|
result = self.capsule.files.read_bytes("/tmp/test.bin")
|
||||||
|
assert result == data
|
||||||
|
|
||||||
|
def test_list_directory(self):
|
||||||
|
self.capsule.files.write("/tmp/listdir/a.txt", "a")
|
||||||
|
self.capsule.files.write("/tmp/listdir/b.txt", "b")
|
||||||
|
entries = self.capsule.files.list("/tmp/listdir")
|
||||||
|
assert isinstance(entries, list)
|
||||||
|
names = [e.name for e in entries]
|
||||||
|
assert "a.txt" in names
|
||||||
|
assert "b.txt" in names
|
||||||
|
|
||||||
|
def test_exists(self):
|
||||||
|
self.capsule.files.write("/tmp/exists_test.txt", "x")
|
||||||
|
assert self.capsule.files.exists("/tmp/exists_test.txt")
|
||||||
|
assert not self.capsule.files.exists("/tmp/does_not_exist_xyz.txt")
|
||||||
|
|
||||||
|
def test_make_dir(self):
|
||||||
|
entry = self.capsule.files.make_dir("/tmp/newdir")
|
||||||
|
assert isinstance(entry, FileEntry)
|
||||||
|
assert self.capsule.files.exists("/tmp/newdir")
|
||||||
|
|
||||||
|
def test_make_dir_idempotent(self):
|
||||||
|
self.capsule.files.make_dir("/tmp/idempotent_dir")
|
||||||
|
entry = self.capsule.files.make_dir("/tmp/idempotent_dir")
|
||||||
|
assert isinstance(entry, FileEntry)
|
||||||
|
|
||||||
|
def test_remove_file(self):
|
||||||
|
self.capsule.files.write("/tmp/to_remove.txt", "delete me")
|
||||||
|
assert self.capsule.files.exists("/tmp/to_remove.txt")
|
||||||
|
self.capsule.files.remove("/tmp/to_remove.txt")
|
||||||
|
assert not self.capsule.files.exists("/tmp/to_remove.txt")
|
||||||
|
|
||||||
|
def test_remove_directory(self):
|
||||||
|
self.capsule.files.make_dir("/tmp/dir_to_remove")
|
||||||
|
self.capsule.files.write("/tmp/dir_to_remove/child.txt", "data")
|
||||||
|
self.capsule.files.remove("/tmp/dir_to_remove")
|
||||||
|
assert not self.capsule.files.exists("/tmp/dir_to_remove")
|
||||||
|
|
||||||
|
def test_write_creates_parent_dirs(self):
|
||||||
|
self.capsule.files.write("/tmp/deep/nested/dir/file.txt", "nested")
|
||||||
|
content = self.capsule.files.read("/tmp/deep/nested/dir/file.txt")
|
||||||
|
assert content == "nested"
|
||||||
|
|
||||||
|
def test_list_with_depth(self):
|
||||||
|
self.capsule.files.write("/tmp/depth_test/a/b.txt", "deep")
|
||||||
|
entries_shallow = self.capsule.files.list("/tmp/depth_test", depth=1)
|
||||||
|
entries_deep = self.capsule.files.list("/tmp/depth_test", depth=2)
|
||||||
|
assert len(entries_deep) >= len(entries_shallow)
|
||||||
|
|
||||||
|
def test_overwrite_file(self):
|
||||||
|
self.capsule.files.write("/tmp/overwrite.txt", "original")
|
||||||
|
self.capsule.files.write("/tmp/overwrite.txt", "updated")
|
||||||
|
content = self.capsule.files.read("/tmp/overwrite.txt")
|
||||||
|
assert content == "updated"
|
||||||
|
|
||||||
|
def test_upload_and_download_stream(self):
|
||||||
|
chunks = [b"chunk1", b"chunk2", b"chunk3"]
|
||||||
|
self.capsule.files.upload_stream("/tmp/streamed.bin", iter(chunks))
|
||||||
|
downloaded = b"".join(self.capsule.files.download_stream("/tmp/streamed.bin"))
|
||||||
|
assert downloaded == b"chunk1chunk2chunk3"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGit:
|
||||||
|
"""Shared capsule for git operation tests.
|
||||||
|
|
||||||
|
Initializes a repo at /root (default cwd) since the exec API
|
||||||
|
does not support the cwd parameter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
cls.capsule.git.init(".", initial_branch="main")
|
||||||
|
cls.capsule.git.configure_user("Test User", "test@example.com")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_init_created_repo(self):
|
||||||
|
assert self.capsule.files.exists("/root/.git")
|
||||||
|
|
||||||
|
def test_status_clean(self):
|
||||||
|
status = self.capsule.git.status()
|
||||||
|
assert status.branch == "main"
|
||||||
|
|
||||||
|
def test_add_and_commit(self):
|
||||||
|
self.capsule.files.write("/root/hello.txt", "hello git")
|
||||||
|
self.capsule.git.add(all=True)
|
||||||
|
result = self.capsule.git.commit("initial commit")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_status_after_commit(self):
|
||||||
|
status = self.capsule.git.status()
|
||||||
|
assert status.is_clean
|
||||||
|
|
||||||
|
def test_status_with_changes(self):
|
||||||
|
self.capsule.files.write("/root/dirty.txt", "uncommitted")
|
||||||
|
try:
|
||||||
|
status = self.capsule.git.status()
|
||||||
|
assert not status.is_clean
|
||||||
|
paths = [f.path for f in status.files]
|
||||||
|
assert "dirty.txt" in paths
|
||||||
|
finally:
|
||||||
|
self.capsule.files.remove("/root/dirty.txt")
|
||||||
|
|
||||||
|
def test_branches(self):
|
||||||
|
branches = self.capsule.git.branches()
|
||||||
|
assert len(branches) >= 1
|
||||||
|
names = [b.name for b in branches]
|
||||||
|
assert "main" in names
|
||||||
|
current = [b for b in branches if b.is_current]
|
||||||
|
assert len(current) == 1
|
||||||
|
|
||||||
|
def test_create_and_checkout_branch(self):
|
||||||
|
self.capsule.git.create_branch("feature-1")
|
||||||
|
branches = self.capsule.git.branches()
|
||||||
|
names = [b.name for b in branches]
|
||||||
|
assert "feature-1" in names
|
||||||
|
|
||||||
|
current = [b for b in branches if b.is_current]
|
||||||
|
assert current[0].name == "feature-1"
|
||||||
|
|
||||||
|
self.capsule.git.checkout_branch("main")
|
||||||
|
|
||||||
|
def test_delete_branch(self):
|
||||||
|
self.capsule.git.create_branch("to-delete")
|
||||||
|
self.capsule.git.checkout_branch("main")
|
||||||
|
self.capsule.git.delete_branch("to-delete")
|
||||||
|
|
||||||
|
branches = self.capsule.git.branches()
|
||||||
|
names = [b.name for b in branches]
|
||||||
|
assert "to-delete" not in names
|
||||||
|
|
||||||
|
def test_set_and_get_config(self):
|
||||||
|
self.capsule.git.set_config("test.key", "test-value")
|
||||||
|
value = self.capsule.git.get_config("test.key")
|
||||||
|
assert value == "test-value"
|
||||||
|
|
||||||
|
def test_get_config_missing_returns_none(self):
|
||||||
|
value = self.capsule.git.get_config("nonexistent.key")
|
||||||
|
assert value is None
|
||||||
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.1"
|
||||||
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