diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..53b599d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# AGENTS.md + +## Project + +Wrenn Python SDK — a client library for the Wrenn microVM platform. e2b drop-in replacement. +Package name: `wrenn`. Python 3.13+, managed with [uv](https://docs.astral.sh/uv/). + +## Commands + +```bash +uv sync # install deps +make lint # ruff check + format check (no auto-fix) +make test # unit tests only (tests/test_client.py) +make test-integration # all tests including integration (needs live server) +make generate # regenerate models from OpenAPI spec (fetches from remote) +make check # lint + unit test +``` + +- `make test` only runs `tests/test_client.py`, not all unit tests. To run a specific test file: `uv run pytest tests/test_capsule_features.py -v` +- No typecheck step in Makefile or CI. `mypy` is a dev dependency but not wired up — do not assume it runs. + +## Architecture + +- `src/wrenn/` — the library package + - `capsule.py` / `async_capsule.py` — high-level `Capsule` / `AsyncCapsule` (main user-facing classes) + - `client.py` — low-level `WrennClient` / `AsyncWrennClient` + - `commands.py` — command execution and streaming + - `files.py` — filesystem operations + - `pty.py` — interactive terminal (PTY) over WebSocket + - `exceptions.py` — typed error hierarchy (`WrennError` base) + - `models/_generated.py` — **auto-generated** from OpenAPI spec via `datamodel-codegen` (never edit directly; run `make generate`) + - `sandbox.py` — deprecated `Sandbox` alias for `Capsule` + - `code_interpreter/` — specialized capsule for stateful Jupyter kernel execution +- `tests/` — unit tests use `respx` to mock `httpx`; integration tests are in `tests/integration/` +- `api/openapi.yaml` — downloaded OpenAPI spec used for code generation + +## Key Conventions + +- Generated code lives in `src/wrenn/models/_generated.py`. Never edit it. Run `make generate` to update. +- `Sandbox` is a deprecated alias for `Capsule`. New code should use `Capsule` / `AsyncCapsule`. +- Dual sync/async API: every major class has an `Async` counterpart. +- Uses `httpx` for HTTP, `httpx-ws` for WebSockets, `pydantic` for models. +- `__init__.py` uses `__getattr__` for lazy deprecated aliases (`Sandbox`, `WrennHostHasSandboxesError`). + +## Testing + +- Unit tests mock HTTP via `respx` (httpx mocking library). +- Integration tests require env vars: `WRENN_API_KEY` (or `WRENN_TOKEN`), optionally `WRENN_BASE_URL`. +- Integration test fixtures in `tests/integration/conftest.py` create real capsules and clean them up. +- `pytest` marker: `@pytest.mark.integration` for tests needing a live server. + +## CI + +Woodpecker CI (`.woodpecker/check.yml`) runs on push to `main` and `dev`: +1. `make lint` +2. `make test` (unit tests only — integration tests are not in CI) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..253c88d --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,104 @@ +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 diff --git a/tests/integration/test_commands.py b/tests/integration/test_commands.py new file mode 100644 index 0000000..01d0253 --- /dev/null +++ b/tests/integration/test_commands.py @@ -0,0 +1,91 @@ +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 diff --git a/tests/integration/test_files.py b/tests/integration/test_files.py new file mode 100644 index 0000000..a7f6e77 --- /dev/null +++ b/tests/integration/test_files.py @@ -0,0 +1,95 @@ +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" diff --git a/tests/integration/test_git.py b/tests/integration/test_git.py new file mode 100644 index 0000000..62cf18e --- /dev/null +++ b/tests/integration/test_git.py @@ -0,0 +1,94 @@ +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 diff --git a/tests/integration/test_lifecycle.py b/tests/integration/test_lifecycle.py new file mode 100644 index 0000000..79232e1 --- /dev/null +++ b/tests/integration/test_lifecycle.py @@ -0,0 +1,119 @@ +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()