Compare commits
6 Commits
bugfix/tim
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4924237a23 | |||
| de72dfe9c8 | |||
| 2b10fde45b | |||
| 800a8566db | |||
| a42f0b2e71 | |||
| be573d07a3 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -181,3 +181,4 @@ CODE_EXECUTION.md
|
|||||||
.code-review-graph/
|
.code-review-graph/
|
||||||
.claude
|
.claude
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
AGENTS.md
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
when:
|
|
||||||
event: pull_request
|
|
||||||
branch:
|
|
||||||
- main
|
|
||||||
- dev
|
|
||||||
path:
|
|
||||||
- "src/**"
|
|
||||||
- "tests/**"
|
|
||||||
|
|
||||||
steps:
|
|
||||||
unit-tests:
|
|
||||||
image: ghcr.io/astral-sh/uv:python3.13-bookworm
|
|
||||||
commands:
|
|
||||||
- uv sync --dev
|
|
||||||
- uv run pytest -m "not integration" -v
|
|
||||||
|
|
||||||
integration-tests:
|
|
||||||
image: ghcr.io/astral-sh/uv:python3.13-bookworm
|
|
||||||
environment:
|
|
||||||
WRENN_API_KEY:
|
|
||||||
from_secret: WRENN_API_KEY
|
|
||||||
commands:
|
|
||||||
- uv sync --dev
|
|
||||||
- uv run pytest -m integration -v
|
|
||||||
20
.woodpecker/code-runner.yml
Normal file
20
.woodpecker/code-runner.yml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# E2E — code_runner. PR to dev/main when code_runner sources/tests change.
|
||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
branch: [main, dev]
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- "src/wrenn/code_runner/**"
|
||||||
|
- "tests/test_code_runner_*.py"
|
||||||
|
- "pyproject.toml"
|
||||||
|
- "uv.lock"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
test-code-runner:
|
||||||
|
image: ghcr.io/astral-sh/uv:python3.13-bookworm
|
||||||
|
environment:
|
||||||
|
WRENN_API_KEY:
|
||||||
|
from_secret: WRENN_API_KEY
|
||||||
|
commands:
|
||||||
|
- uv sync --dev
|
||||||
|
- make test-code-runner
|
||||||
25
.woodpecker/integration.yml
Normal file
25
.woodpecker/integration.yml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# E2E — integration. PR to dev/main when non-code_runner src changes.
|
||||||
|
# Path filter: include src/** but exclude src/wrenn/code_runner/** so the
|
||||||
|
# dedicated code-runner pipeline owns that surface.
|
||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
branch: [main, dev]
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- "src/**"
|
||||||
|
- "tests/**"
|
||||||
|
- "pyproject.toml"
|
||||||
|
- "uv.lock"
|
||||||
|
exclude:
|
||||||
|
- "src/wrenn/code_runner/**"
|
||||||
|
- "tests/test_code_runner_*.py"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
test-integration:
|
||||||
|
image: ghcr.io/astral-sh/uv:python3.13-bookworm
|
||||||
|
environment:
|
||||||
|
WRENN_API_KEY:
|
||||||
|
from_secret: WRENN_API_KEY
|
||||||
|
commands:
|
||||||
|
- uv sync --dev
|
||||||
|
- make test-integration
|
||||||
11
.woodpecker/unit.yml
Normal file
11
.woodpecker/unit.yml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Unit tests — every push and pull_request, all branches.
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
steps:
|
||||||
|
unit-tests:
|
||||||
|
image: ghcr.io/astral-sh/uv:python3.13-bookworm
|
||||||
|
commands:
|
||||||
|
- uv sync --dev
|
||||||
|
- uv run pytest -m "not integration" -v
|
||||||
56
AGENTS.md
56
AGENTS.md
@ -1,56 +0,0 @@
|
|||||||
# 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)
|
|
||||||
230
CLAUDE.md
Normal file
230
CLAUDE.md
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
## Design Context
|
||||||
|
|
||||||
|
### Users
|
||||||
|
Developers across the full spectrum — solo engineers building side projects, startup teams integrating sandboxed execution into products, and platform/infra engineers at larger organizations running production workloads on Firecracker microVMs. They arrive with context: they know what a process is, what a rootfs is, what a TTY means. The interface must feel at home for all three: approachable enough not to intimidate a hacker, precise enough to earn the trust of a production ops team. Never condescend, never oversimplify. Trust the user to understand what they're looking at.
|
||||||
|
|
||||||
|
**Primary job to be done:** Understand what's running, act on it confidently, and get back to code.
|
||||||
|
|
||||||
|
### Brand Personality
|
||||||
|
**Precise. Warm. Uncompromising.**
|
||||||
|
|
||||||
|
Wrenn is an engineer's favorite tool — built with visible care, not assembled from defaults. It runs real infrastructure (Firecracker microVMs), so the UI should reflect that seriousness without becoming cold or corporate. The warmth comes from the typography and color palette; the precision comes from hierarchy, density, and data fidelity.
|
||||||
|
|
||||||
|
Emotional goal: **in control.** Users leave a session with full confidence in what's running, what happened, and what comes next. Nothing is hidden, nothing is ambiguous.
|
||||||
|
|
||||||
|
### Aesthetic Direction
|
||||||
|
**Dark-only (permanently), industrial-warm, data-forward.**
|
||||||
|
|
||||||
|
No light mode planned. All design decisions should optimize for dark. The near-black-green background palette (`#0a0c0b` through `#2a302d`) reads as "black with intention" — not pitch black (cold) and not charcoal (dated). The sage green accent (`#5e8c58`) is muted and organic, a meaningful departure from the startup-green neon that saturates the developer tool space.
|
||||||
|
|
||||||
|
**Anti-references:**
|
||||||
|
- **Supabase**: avoid the friendly, approachable startup-green energy — too generic, too eager to please
|
||||||
|
- **AWS / GCP consoles**: avoid utility-first density without craft — functional but joyless, visually dated
|
||||||
|
|
||||||
|
**References that capture the right spirit:**
|
||||||
|
- The precision of a well-calibrated instrument
|
||||||
|
- Editorial typography from technical publications
|
||||||
|
- The quiet confidence of tools that don't need to explain themselves
|
||||||
|
|
||||||
|
### Type System
|
||||||
|
Four fonts with strict roles — this is the design system's strongest personality trait and must be respected:
|
||||||
|
|
||||||
|
| Font | CSS Class | Role | When to use |
|
||||||
|
|------|-----------|------|-------------|
|
||||||
|
| **Manrope** (variable, sans) | `font-sans` | UI workhorse | All body copy, nav, labels, buttons, form text |
|
||||||
|
| **Instrument Serif** | `font-serif` | Display / editorial | Page titles (h1), dialog headings, metric values, hero moments |
|
||||||
|
| **JetBrains Mono** (variable) | `font-mono` | Data / code | IDs, timestamps, key prefixes, file paths, terminal output, metrics |
|
||||||
|
| **Alice** | brand wordmark only | Brand wordmark | "Wrenn" in sidebar and login only — nowhere else |
|
||||||
|
|
||||||
|
Instrument Serif at scale creates the signature editorial moments. Mono provides the precision signal for technical data. Never swap these roles.
|
||||||
|
|
||||||
|
**Tracking overrides (app.css):**
|
||||||
|
- `.font-serif` — `letter-spacing: 0.015em` (positive tracking; Instrument Serif reads less condensed at display sizes)
|
||||||
|
- `.font-mono` — `font-variant-numeric: tabular-nums` (numbers align in tables and metric displays)
|
||||||
|
|
||||||
|
**Type scale (root: 87.5% = 14px base):**
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--text-display` | 2.571rem (~36px) | Auth section headings |
|
||||||
|
| `--text-page` | 2rem (~28px) | Page h1 titles |
|
||||||
|
| `--text-heading` | 1.429rem (~20px) | Dialog headings, empty states |
|
||||||
|
| `--text-body` | 1rem (~14px) | Primary body, buttons, inputs |
|
||||||
|
| `--text-ui` | 0.929rem (~13px) | Nav labels, table cells |
|
||||||
|
| `--text-meta` | 0.857rem (~12px) | Key prefixes, minor info |
|
||||||
|
| `--text-label` | 0.786rem (~11px) | Uppercase section labels |
|
||||||
|
| `--text-badge` | 0.714rem (~10px) | Live badges, tiny indicators |
|
||||||
|
|
||||||
|
### Color System
|
||||||
|
|
||||||
|
All values are CSS custom properties in `frontend/src/app.css`.
|
||||||
|
|
||||||
|
**Backgrounds (6-step near-black-green scale):**
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-bg-0` | `#0a0c0b` | Page base, sidebar deepest layer |
|
||||||
|
| `--color-bg-1` | `#0f1211` | Sidebar surface |
|
||||||
|
| `--color-bg-2` | `#141817` | Card backgrounds |
|
||||||
|
| `--color-bg-3` | `#1a1e1c` | Table headers, elevated surfaces |
|
||||||
|
| `--color-bg-4` | `#212624` | Hover states, inputs |
|
||||||
|
| `--color-bg-5` | `#2a302d` | Highlighted items, selected rows |
|
||||||
|
|
||||||
|
**Text (5-level hierarchy):**
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-text-bright` | `#eae7e2` | H1s, dialog headings |
|
||||||
|
| `--color-text-primary` | `#d0cdc6` | Body copy, primary labels |
|
||||||
|
| `--color-text-secondary` | `#9b9790` | Secondary labels, descriptions |
|
||||||
|
| `--color-text-tertiary` | `#6b6862` | Hints, placeholders |
|
||||||
|
| `--color-text-muted` | `#454340` | Dividers as text, ultra-subtle |
|
||||||
|
|
||||||
|
**Accent (sage green — use sparingly, must feel earned):**
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-accent` | `#5e8c58` | Primary CTA, live indicators, focus rings, active nav |
|
||||||
|
| `--color-accent-mid` | `#89a785` | Hover accent text |
|
||||||
|
| `--color-accent-bright` | `#a4c89f` | Accent on dark backgrounds |
|
||||||
|
| `--color-accent-glow` | `rgba(94,140,88,0.07)` | Subtle tinted backgrounds |
|
||||||
|
| `--color-accent-glow-mid` | `rgba(94,140,88,0.14)` | Hover tint on accent items |
|
||||||
|
|
||||||
|
**Status semantics:**
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-amber` | `#d4a73c` | Warning, paused state |
|
||||||
|
| `--color-red` | `#cf8172` | Error, destructive actions |
|
||||||
|
| `--color-blue` | `#5a9fd4` | Info, neutral system states |
|
||||||
|
|
||||||
|
**Borders:** `--color-border` (`#1f2321`) default; `--color-border-mid` (`#2a2f2c`) for inputs/hover.
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
|
||||||
|
**Buttons:**
|
||||||
|
- Primary: solid sage green (`--color-accent`), hover brightness boost + micro-lift (`-translate-y-px`)
|
||||||
|
- Secondary: bordered (`--color-border-mid`), text transitions to accent on hover
|
||||||
|
- Danger: red text + subtle red background on hover
|
||||||
|
- All: `transition-all duration-150`
|
||||||
|
|
||||||
|
**Inputs:**
|
||||||
|
- Border `--color-border`, background `--color-bg-2`; focus transitions border and icon to accent
|
||||||
|
- Group focus pattern: `group` wrapper + `group-focus-within:text-[var(--color-accent)]` on icon
|
||||||
|
|
||||||
|
**Tables / data lists:**
|
||||||
|
- Grid layout; header `bg-3` + uppercase `--text-label`; row hover `hover:bg-[var(--color-bg-3)]`
|
||||||
|
- Status stripe: left border color matches sandbox state
|
||||||
|
|
||||||
|
**Status indicators:** Running = animated ping + sage green dot; Paused = amber dot; Stopped = muted gray. Color is never the sole differentiator.
|
||||||
|
|
||||||
|
**Modals & dialogs:** Border + shadow only — no accent gradient bars/strips. `fadeUp` 0.35s entrance.
|
||||||
|
|
||||||
|
**Empty states:** Large icon with glow, Instrument Serif heading, secondary body text, CTA below, `iconFloat` 4s animation.
|
||||||
|
|
||||||
|
**Animations (always respect `prefers-reduced-motion`):** `fadeUp` (entrance), `status-ping` (live indicator), `iconFloat` (empty states), `spin-once` (refresh), staggered `animation-delay` on lists.
|
||||||
|
|
||||||
|
### Design Principles
|
||||||
|
|
||||||
|
1. **Precision over friendliness.** Every element earns its place. Wrenn doesn't need to tell you it's developer-friendly — that should be self-evident from the quality of the information architecture.
|
||||||
|
|
||||||
|
2. **Density with breathing room.** Data-forward doesn't mean cramped. Strategic whitespace creates calm hierarchy within dense contexts. Sections breathe; rows don't waste space.
|
||||||
|
|
||||||
|
3. **Industrial warmth.** The serif + mono + warm-black combination prevents sterility. This is a forge, not a gallery. The warmth is in the details, not the primary colors.
|
||||||
|
|
||||||
|
4. **Legible at speed.** Users scan dashboards in seconds. Strong typographic contrast (serif h1, mono IDs, sans body), consistent patterns, and predictable placement let users orientate instantly without reading everything.
|
||||||
|
|
||||||
|
5. **Craft signals trust.** For infrastructure that runs production code, the quality of the UI is a proxy for the quality of the product. Pixel-level decisions matter. Polish is not decoration — it's a trust signal.
|
||||||
|
|
||||||
|
<!-- code-review-graph MCP tools -->
|
||||||
|
## MCP Tools: code-review-graph
|
||||||
|
|
||||||
|
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||||
|
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||||
|
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||||
|
you structural context (callers, dependents, test coverage) that file
|
||||||
|
scanning cannot.
|
||||||
|
|
||||||
|
### When to use graph tools FIRST
|
||||||
|
|
||||||
|
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||||
|
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||||
|
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||||
|
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||||
|
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||||
|
|
||||||
|
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||||
|
|
||||||
|
### Key Tools
|
||||||
|
|
||||||
|
| Tool | Use when |
|
||||||
|
|------|----------|
|
||||||
|
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||||
|
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||||
|
| `get_impact_radius` | Understanding blast radius of a change |
|
||||||
|
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||||
|
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||||
|
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||||
|
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||||
|
| `refactor_tool` | Planning renames, finding dead code |
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
1. The graph auto-updates on file changes (via hooks).
|
||||||
|
2. Use `detect_changes` for code review.
|
||||||
|
3. Use `get_affected_flows` to understand impact.
|
||||||
|
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||||
|
|
||||||
|
## Code Runner Module
|
||||||
|
|
||||||
|
`wrenn.code_runner` — stateful code execution capsule via persistent
|
||||||
|
Jupyter kernel.
|
||||||
|
|
||||||
|
- **Module path:** `wrenn.code_runner` (canonical). The old path
|
||||||
|
`wrenn.code_interpreter` is a deprecation alias that emits a
|
||||||
|
`FutureWarning` on import; do not introduce new uses.
|
||||||
|
- **Defaults:** template `code-runner-beta`, kernelspec `wrenn`.
|
||||||
|
Both overridable via `Capsule(template=..., kernel=...)`.
|
||||||
|
- **Kernel reuse:** `_ensure_kernel` lists `/api/kernels`, reuses the
|
||||||
|
first kernel whose `name` matches the configured kernelspec, else
|
||||||
|
POSTs `{"name": <kernel>}` to create one. Matching by name (not just
|
||||||
|
"any kernel") is intentional — multiple kernelspecs may coexist on
|
||||||
|
the same Jupyter.
|
||||||
|
- **Lifecycle invariant:** the constructor sets `_kernel_id`,
|
||||||
|
`_kernel_name`, `_proxy_client` to safe defaults *before* calling
|
||||||
|
`super().__init__`. `__del__` must never assume construction
|
||||||
|
completed. Async `__del__` only drops the reference — the proxy
|
||||||
|
`httpx.AsyncClient` must be closed via `await close()` or
|
||||||
|
`async with`.
|
||||||
|
|
||||||
|
## Client Config
|
||||||
|
|
||||||
|
`WrennClient` / `AsyncWrennClient` accept:
|
||||||
|
- `api_key` — falls back to `WRENN_API_KEY`.
|
||||||
|
- `base_url` — falls back to `WRENN_BASE_URL`, then `DEFAULT_BASE_URL`
|
||||||
|
(`https://app.wrenn.dev/api`).
|
||||||
|
- `proxy_domain` — host suffix for capsule proxy URLs
|
||||||
|
(`{port}-{capsule_id}.<domain>`). Resolution:
|
||||||
|
1. explicit `proxy_domain=` kwarg
|
||||||
|
2. `WRENN_PROXY_DOMAIN` env
|
||||||
|
3. `wrenn.dev` when `base_url` host == `app.wrenn.dev` exactly
|
||||||
|
4. else `base_url` host (with port) verbatim
|
||||||
|
Exact match in step 3 is intentional: staging/other Wrenn envs keep
|
||||||
|
their host so they don't accidentally collapse to prod `wrenn.dev`.
|
||||||
|
- `timeout` — `httpx.Timeout | float | None`. Default
|
||||||
|
`httpx.Timeout(30.0, connect=10.0)`. Helper `_resolve_timeout`
|
||||||
|
centralizes the float-or-Timeout coercion.
|
||||||
|
|
||||||
|
`_build_proxy_url` / `_build_http_proxy_url` in `wrenn.capsule` now take
|
||||||
|
an optional `proxy_domain` arg. When omitted they fall back to the
|
||||||
|
`base_url` host (legacy behavior, preserved for direct callers/tests).
|
||||||
|
Production call sites pass `self._client._proxy_domain`.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `tests/test_code_runner_unit.py` — pure unit tests (respx + mocked
|
||||||
|
WebSocket). Covers `Result.from_bundle`, MIME unpacking,
|
||||||
|
quote-stripping, `Execution.text`, kernel reuse vs create, retry on
|
||||||
|
5xx, 4xx propagation, ctor-failure-safe `__del__`, deprecation
|
||||||
|
alias.
|
||||||
|
- `tests/test_code_runner_e2e.py` — live integration tests (marked
|
||||||
|
`integration`, skipped without `WRENN_API_KEY`). Covers stateful
|
||||||
|
execution, exceptions, callbacks, rich outputs (HTML, matplotlib,
|
||||||
|
pandas), async variant, isolation between capsules, and the
|
||||||
|
deprecated `code_interpreter` import path.
|
||||||
|
- Run both: `make test-code-runner`.
|
||||||
9
Makefile
9
Makefile
@ -1,5 +1,5 @@
|
|||||||
# Makefile
|
# Makefile
|
||||||
.PHONY: generate lint test check test-integration
|
.PHONY: generate lint test check test-integration test-code-runner
|
||||||
|
|
||||||
# Variables
|
# Variables
|
||||||
SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml"
|
SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml"
|
||||||
@ -30,10 +30,13 @@ lint:
|
|||||||
uv run ruff format --check src/
|
uv run ruff format --check src/
|
||||||
|
|
||||||
test:
|
test:
|
||||||
uv run pytest tests/test_client.py -v
|
uv run pytest tests/test_client.py tests/test_code_runner_unit.py -v
|
||||||
|
|
||||||
test-integration:
|
test-integration:
|
||||||
uv run pytest tests/ -v -m "integration or not integration"
|
uv run pytest tests/ -v -m "integration or not integration" --ignore=tests/test_code_runner_e2e.py --ignore=tests/test_code_runner_unit.py
|
||||||
|
|
||||||
|
test-code-runner:
|
||||||
|
uv run pytest tests/test_code_runner_unit.py tests/test_code_runner_e2e.py -v -m "integration or not integration"
|
||||||
|
|
||||||
check: lint test
|
check: lint test
|
||||||
|
|
||||||
|
|||||||
113
README.md
113
README.md
@ -26,10 +26,31 @@ Optionally override the API base URL:
|
|||||||
export WRENN_BASE_URL="https://app.wrenn.dev/api" # default
|
export WRENN_BASE_URL="https://app.wrenn.dev/api" # default
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For self-hosted deployments you can also override the capsule proxy domain
|
||||||
|
(used to build `{port}-{capsule_id}.<domain>` URLs returned by
|
||||||
|
`Capsule.get_url`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export WRENN_PROXY_DOMAIN="wrenn.example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolution order: explicit `proxy_domain=` kwarg → `WRENN_PROXY_DOMAIN` env →
|
||||||
|
`wrenn.dev` when `base_url` is the default `app.wrenn.dev` host, else the
|
||||||
|
`base_url` host (with port) verbatim.
|
||||||
|
|
||||||
You can also pass credentials directly:
|
You can also pass credentials directly:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from wrenn import Capsule
|
from wrenn import WrennClient, Capsule
|
||||||
|
|
||||||
|
# WrennClient also accepts a timeout (httpx.Timeout or float seconds).
|
||||||
|
# Default: 30s read/write/pool, 10s connect.
|
||||||
|
client = WrennClient(
|
||||||
|
api_key="wrn_...",
|
||||||
|
base_url="https://...",
|
||||||
|
proxy_domain="wrenn.example.com", # optional override
|
||||||
|
timeout=30.0, # optional override
|
||||||
|
)
|
||||||
|
|
||||||
capsule = Capsule(api_key="wrn_...", base_url="https://...")
|
capsule = Capsule(api_key="wrn_...", base_url="https://...")
|
||||||
```
|
```
|
||||||
@ -84,10 +105,10 @@ capsule = Capsule.connect("cl-abc123")
|
|||||||
result = capsule.commands.run("echo still running")
|
result = capsule.commands.run("echo still running")
|
||||||
```
|
```
|
||||||
|
|
||||||
For code interpreter capsules:
|
For code runner capsules:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from wrenn.code_interpreter import Capsule as CodeCapsule
|
from wrenn.code_runner import Capsule as CodeCapsule
|
||||||
|
|
||||||
capsule = CodeCapsule.connect("cl-abc123")
|
capsule = CodeCapsule.connect("cl-abc123")
|
||||||
result = capsule.run_code("print('reconnected')")
|
result = capsule.run_code("print('reconnected')")
|
||||||
@ -151,6 +172,8 @@ import sys
|
|||||||
# Stream a new command
|
# Stream a new command
|
||||||
for event in capsule.commands.stream("python", args=["-u", "train.py"]):
|
for event in capsule.commands.stream("python", args=["-u", "train.py"]):
|
||||||
match event.type:
|
match event.type:
|
||||||
|
case "start":
|
||||||
|
print(f"PID: {event.pid}")
|
||||||
case "stdout":
|
case "stdout":
|
||||||
print(event.data, end="")
|
print(event.data, end="")
|
||||||
case "stderr":
|
case "stderr":
|
||||||
@ -160,8 +183,11 @@ for event in capsule.commands.stream("python", args=["-u", "train.py"]):
|
|||||||
|
|
||||||
# Connect to a running background process
|
# Connect to a running background process
|
||||||
for event in capsule.commands.connect(handle.pid):
|
for event in capsule.commands.connect(handle.pid):
|
||||||
if event.type == "stdout":
|
match event.type:
|
||||||
print(event.data, end="")
|
case "start":
|
||||||
|
print(f"PID: {event.pid}")
|
||||||
|
case "stdout":
|
||||||
|
print(event.data, end="")
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Process Management
|
#### Process Management
|
||||||
@ -190,6 +216,7 @@ capsule.files.exists("/app/main.py") # True
|
|||||||
|
|
||||||
# List directory
|
# List directory
|
||||||
entries = capsule.files.list("/home/user", depth=1)
|
entries = capsule.files.list("/home/user", depth=1)
|
||||||
|
# FileEntry has: name, type (file/dir), size, modified_at
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
print(entry.name, entry.type, entry.size)
|
print(entry.name, entry.type, entry.size)
|
||||||
|
|
||||||
@ -268,8 +295,27 @@ value = capsule.git.get_config("user.name", cwd="/app") # str | None
|
|||||||
|
|
||||||
capsule.git.remote_add("upstream", "https://github.com/org/repo.git", cwd="/app")
|
capsule.git.remote_add("upstream", "https://github.com/org/repo.git", cwd="/app")
|
||||||
url = capsule.git.remote_get("origin", cwd="/app") # str | None
|
url = capsule.git.remote_get("origin", cwd="/app") # str | None
|
||||||
|
|
||||||
|
# Reset and restore
|
||||||
|
capsule.git.reset(mode="hard", ref="HEAD~1", cwd="/app")
|
||||||
|
capsule.git.restore(["file.txt"], staged=True, cwd="/app")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Persistent Credential Store
|
||||||
|
|
||||||
|
For workflows that need repeated authenticated operations, you can persist credentials via the git credential store:
|
||||||
|
|
||||||
|
```python
|
||||||
|
capsule.git.dangerously_authenticate(
|
||||||
|
username="user",
|
||||||
|
password="ghp_token",
|
||||||
|
host="github.com",
|
||||||
|
protocol="https",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Warning:** Credentials are written in plaintext inside the capsule and are accessible to any process running there. Prefer per-operation `username`/`password` on `clone`, `push`, and `pull` instead.
|
||||||
|
|
||||||
Git errors raise `GitCommandError` (or `GitAuthError` for authentication failures), both inheriting from `GitError`:
|
Git errors raise `GitCommandError` (or `GitAuthError` for authentication failures), both inheriting from `GitError`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@ -287,7 +333,7 @@ except GitAuthError as e:
|
|||||||
```python
|
```python
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
with capsule.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term:
|
with capsule.pty(cmd="/bin/bash", cols=80, rows=24, cwd="/home/user") as term:
|
||||||
term.write(b"ls -la\n")
|
term.write(b"ls -la\n")
|
||||||
for event in term:
|
for event in term:
|
||||||
if event.type == "output":
|
if event.type == "output":
|
||||||
@ -329,14 +375,16 @@ template = capsule.create_snapshot(name="my-template", overwrite=True)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Code Interpreter
|
## Code Runner
|
||||||
|
|
||||||
The `wrenn.code_interpreter` module provides a specialized capsule for stateful code execution via a persistent Jupyter kernel.
|
The `wrenn.code_runner` module provides a specialized capsule for stateful code execution via a persistent Jupyter kernel. Defaults to the `code-runner-beta` template and the `wrenn` Jupyter kernelspec.
|
||||||
|
|
||||||
|
> The legacy module path `wrenn.code_interpreter` still works but emits a `FutureWarning` on import. Use `wrenn.code_runner`.
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from wrenn.code_interpreter import Capsule
|
from wrenn.code_runner import Capsule
|
||||||
|
|
||||||
with Capsule(wait=True) as capsule:
|
with Capsule(wait=True) as capsule:
|
||||||
result = capsule.run_code("print('hello')")
|
result = capsule.run_code("print('hello')")
|
||||||
@ -348,7 +396,7 @@ with Capsule(wait=True) as capsule:
|
|||||||
Variables, imports, and function definitions persist across `run_code` calls:
|
Variables, imports, and function definitions persist across `run_code` calls:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from wrenn.code_interpreter import Capsule
|
from wrenn.code_runner import Capsule
|
||||||
|
|
||||||
with Capsule(wait=True) as capsule:
|
with Capsule(wait=True) as capsule:
|
||||||
capsule.run_code("x = 42")
|
capsule.run_code("x = 42")
|
||||||
@ -403,15 +451,21 @@ capsule.run_code(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom Templates
|
### Custom Templates and Kernels
|
||||||
|
|
||||||
By default, `code-runner-beta` template is used. You can specify a custom template:
|
By default, the `code-runner-beta` template and the `wrenn` Jupyter kernelspec are used. Override either:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
capsule = Capsule(template="my-custom-jupyter-template", wait=True)
|
capsule = Capsule(
|
||||||
|
template="my-custom-jupyter-template",
|
||||||
|
kernel="python3",
|
||||||
|
wait=True,
|
||||||
|
)
|
||||||
result = capsule.run_code("print('running on custom template')")
|
result = capsule.run_code("print('running on custom template')")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`Capsule` reuses the first kernel matching the requested `kernel` name on the Jupyter server and creates one if none exists.
|
||||||
|
|
||||||
### Execution Model
|
### Execution Model
|
||||||
|
|
||||||
`run_code()` returns an `Execution` object:
|
`run_code()` returns an `Execution` object:
|
||||||
@ -422,16 +476,17 @@ result = capsule.run_code("print('running on custom template')")
|
|||||||
| `logs` | `Logs` | `.stdout: list[str]` and `.stderr: list[str]` chunks |
|
| `logs` | `Logs` | `.stdout: list[str]` and `.stderr: list[str]` chunks |
|
||||||
| `error` | `ExecutionError \| None` | `.name`, `.value`, `.traceback` |
|
| `error` | `ExecutionError \| None` | `.name`, `.value`, `.traceback` |
|
||||||
| `execution_count` | `int \| None` | Jupyter cell execution counter |
|
| `execution_count` | `int \| None` | Jupyter cell execution counter |
|
||||||
|
| `timed_out` | `bool` | ``True`` when execution was cut short by the timeout |
|
||||||
| `text` | `str \| None` | (property) `text/plain` of the main `execute_result` |
|
| `text` | `str \| None` | (property) `text/plain` of the main `execute_result` |
|
||||||
|
|
||||||
Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `pdf`, `latex`, `json`, `javascript`, plus `extra` for unknown types. String expression results have quotes stripped automatically.
|
Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `gif`, `pdf`, `latex`, `json`, `javascript`, `plotly`, plus `extra` for unknown types. The `text` field is Jupyter's `text/plain` bundle verbatim — the Python `repr()` of the cell's last expression. So `run_code("'hi'").text` is `"'hi'"` (with quotes), and `run_code("42").text` is `"42"`. This preserves the distinction between the string `'2'` and the int `2`.
|
||||||
|
|
||||||
### Code Interpreter + Commands/Files
|
### Code Runner + Commands/Files
|
||||||
|
|
||||||
The code interpreter capsule inherits all standard capsule features:
|
The code runner capsule inherits all standard capsule features:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from wrenn.code_interpreter import Capsule
|
from wrenn.code_runner import Capsule
|
||||||
|
|
||||||
with Capsule(wait=True) as capsule:
|
with Capsule(wait=True) as capsule:
|
||||||
# Use run_code for Jupyter execution
|
# Use run_code for Jupyter execution
|
||||||
@ -469,10 +524,10 @@ async with await AsyncCapsule.create(template="minimal", wait=True) as capsule:
|
|||||||
await capsule.resume()
|
await capsule.resume()
|
||||||
```
|
```
|
||||||
|
|
||||||
### Async Code Interpreter
|
### Async Code Runner
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from wrenn.code_interpreter import AsyncCapsule
|
from wrenn.code_runner import AsyncCapsule
|
||||||
|
|
||||||
async with await AsyncCapsule.create(wait=True) as capsule:
|
async with await AsyncCapsule.create(wait=True) as capsule:
|
||||||
result = await capsule.run_code("2 + 2")
|
result = await capsule.run_code("2 + 2")
|
||||||
@ -498,15 +553,15 @@ The SDK maps server error codes to typed exceptions:
|
|||||||
```python
|
```python
|
||||||
from wrenn import (
|
from wrenn import (
|
||||||
WrennError,
|
WrennError,
|
||||||
WrennValidationError, # 400
|
WrennValidationError, # 400
|
||||||
WrennAuthenticationError, # 401
|
WrennAuthenticationError, # 401
|
||||||
WrennForbiddenError, # 403
|
WrennForbiddenError, # 403
|
||||||
WrennNotFoundError, # 404
|
WrennNotFoundError, # 404
|
||||||
WrennConflictError, # 409
|
WrennConflictError, # 409
|
||||||
WrennHostHasCapsulesError, # 409 (host has running capsules)
|
WrennHostHasCapsulesError, # 409 (host has running capsules)
|
||||||
WrennAgentError, # 502
|
WrennInternalError, # 500
|
||||||
WrennInternalError, # 500
|
WrennAgentError, # 502
|
||||||
WrennHostUnavailableError, # 503
|
WrennHostUnavailableError, # 503
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -574,7 +629,7 @@ with WrennClient(api_key="wrn_...") as client:
|
|||||||
|
|
||||||
# Snapshots
|
# Snapshots
|
||||||
template = client.snapshots.create(capsule_id="cl-abc", name="my-snap")
|
template = client.snapshots.create(capsule_id="cl-abc", name="my-snap")
|
||||||
templates = client.snapshots.list()
|
templates = client.snapshots.list(type="custom") # optional type filter
|
||||||
client.snapshots.delete("my-snap")
|
client.snapshots.delete("my-snap")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
1589
api/openapi.yaml
1589
api/openapi.yaml
File diff suppressed because it is too large
Load Diff
1048
docs/reference.md
1048
docs/reference.md
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "wrenn"
|
name = "wrenn"
|
||||||
version = "0.1.1"
|
version = "0.2.0"
|
||||||
description = "Python SDK for Wrenn"
|
description = "Python SDK for Wrenn"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@ -22,6 +22,7 @@ classifiers = [
|
|||||||
"Typing :: Typed",
|
"Typing :: Typed",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"certifi>=2026.2.25",
|
||||||
"email-validator>=2.3.0",
|
"email-validator>=2.3.0",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"httpx-ws>=0.9.0",
|
"httpx-ws>=0.9.0",
|
||||||
|
|||||||
@ -37,7 +37,7 @@ from wrenn.exceptions import (
|
|||||||
from wrenn.models import FileEntry
|
from wrenn.models import FileEntry
|
||||||
from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession
|
from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.4"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"__version__",
|
"__version__",
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
DEFAULT_BASE_URL = "https://app.wrenn.dev/api"
|
DEFAULT_BASE_URL = "https://app.wrenn.dev/api"
|
||||||
|
DEFAULT_PROXY_DOMAIN = "wrenn.dev"
|
||||||
ENV_API_KEY = "WRENN_API_KEY"
|
ENV_API_KEY = "WRENN_API_KEY"
|
||||||
ENV_BASE_URL = "WRENN_BASE_URL"
|
ENV_BASE_URL = "WRENN_BASE_URL"
|
||||||
|
ENV_PROXY_DOMAIN = "WRENN_PROXY_DOMAIN"
|
||||||
|
|||||||
@ -153,6 +153,20 @@ class Git:
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _run_op(
|
||||||
|
self,
|
||||||
|
argv: list[str],
|
||||||
|
*,
|
||||||
|
op: str,
|
||||||
|
cwd: str | None = None,
|
||||||
|
envs: dict[str, str] | None = None,
|
||||||
|
timeout: int | None = 30,
|
||||||
|
) -> CommandResult:
|
||||||
|
"""``_run`` + :func:`_check_result` in one call. Raises on failure."""
|
||||||
|
result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
||||||
|
_check_result(result, op=op)
|
||||||
|
return result
|
||||||
|
|
||||||
# ── Repository setup ───────────────────────────────────────
|
# ── Repository setup ───────────────────────────────────────
|
||||||
|
|
||||||
def clone(
|
def clone(
|
||||||
@ -203,8 +217,7 @@ class Git:
|
|||||||
clone_url = embed_credentials(url, username, password)
|
clone_url = embed_credentials(url, username, password)
|
||||||
|
|
||||||
argv = build_clone(clone_url, dest, branch=branch, depth=depth)
|
argv = build_clone(clone_url, dest, branch=branch, depth=depth)
|
||||||
result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = self._run_op(argv, op="clone", cwd=cwd, envs=envs, timeout=timeout)
|
||||||
_check_result(result, op="clone")
|
|
||||||
|
|
||||||
if username and password and not dangerously_store_credentials:
|
if username and password and not dangerously_store_credentials:
|
||||||
sanitized = strip_credentials(clone_url)
|
sanitized = strip_credentials(clone_url)
|
||||||
@ -248,8 +261,7 @@ class Git:
|
|||||||
GitCommandError: If init failed.
|
GitCommandError: If init failed.
|
||||||
"""
|
"""
|
||||||
argv = build_init(path, bare=bare, initial_branch=initial_branch)
|
argv = build_init(path, bare=bare, initial_branch=initial_branch)
|
||||||
result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = self._run_op(argv, op="init", cwd=cwd, envs=envs, timeout=timeout)
|
||||||
_check_result(result, op="init")
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── Staging and committing ─────────────────────────────────
|
# ── Staging and committing ─────────────────────────────────
|
||||||
@ -280,8 +292,7 @@ class Git:
|
|||||||
GitCommandError: If add failed.
|
GitCommandError: If add failed.
|
||||||
"""
|
"""
|
||||||
argv = build_add(paths, all=all)
|
argv = build_add(paths, all=all)
|
||||||
result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = self._run_op(argv, op="add", cwd=cwd, envs=envs, timeout=timeout)
|
||||||
_check_result(result, op="add")
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def commit(
|
def commit(
|
||||||
@ -318,8 +329,7 @@ class Git:
|
|||||||
author_name=author_name,
|
author_name=author_name,
|
||||||
author_email=author_email,
|
author_email=author_email,
|
||||||
)
|
)
|
||||||
result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = self._run_op(argv, op="commit", cwd=cwd, envs=envs, timeout=timeout)
|
||||||
_check_result(result, op="commit")
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── Remote sync ────────────────────────────────────────────
|
# ── Remote sync ────────────────────────────────────────────
|
||||||
@ -375,8 +385,7 @@ class Git:
|
|||||||
)
|
)
|
||||||
|
|
||||||
argv = build_push(remote, branch, force=force, set_upstream=set_upstream)
|
argv = build_push(remote, branch, force=force, set_upstream=set_upstream)
|
||||||
result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = self._run_op(argv, op="push", cwd=cwd, envs=envs, timeout=timeout)
|
||||||
_check_result(result, op="push")
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def pull(
|
def pull(
|
||||||
@ -430,8 +439,7 @@ class Git:
|
|||||||
)
|
)
|
||||||
|
|
||||||
argv = build_pull(remote, branch, rebase=rebase, ff_only=ff_only)
|
argv = build_pull(remote, branch, rebase=rebase, ff_only=ff_only)
|
||||||
result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = self._run_op(argv, op="pull", cwd=cwd, envs=envs, timeout=timeout)
|
||||||
_check_result(result, op="pull")
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── Status and branches ────────────────────────────────────
|
# ── Status and branches ────────────────────────────────────
|
||||||
@ -456,8 +464,9 @@ class Git:
|
|||||||
Raises:
|
Raises:
|
||||||
GitCommandError: If the command failed.
|
GitCommandError: If the command failed.
|
||||||
"""
|
"""
|
||||||
result = self._run(build_status(), cwd=cwd, envs=envs, timeout=timeout)
|
result = self._run_op(
|
||||||
_check_result(result, op="status")
|
build_status(), op="status", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return parse_status(result.stdout)
|
return parse_status(result.stdout)
|
||||||
|
|
||||||
def branches(
|
def branches(
|
||||||
@ -480,8 +489,9 @@ class Git:
|
|||||||
Raises:
|
Raises:
|
||||||
GitCommandError: If the command failed.
|
GitCommandError: If the command failed.
|
||||||
"""
|
"""
|
||||||
result = self._run(build_branches(), cwd=cwd, envs=envs, timeout=timeout)
|
result = self._run_op(
|
||||||
_check_result(result, op="branches")
|
build_branches(), op="branches", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return parse_branches(result.stdout)
|
return parse_branches(result.stdout)
|
||||||
|
|
||||||
def create_branch(
|
def create_branch(
|
||||||
@ -509,8 +519,9 @@ class Git:
|
|||||||
GitCommandError: If the command failed.
|
GitCommandError: If the command failed.
|
||||||
"""
|
"""
|
||||||
argv = build_create_branch(name, start_point=start_point)
|
argv = build_create_branch(name, start_point=start_point)
|
||||||
result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = self._run_op(
|
||||||
_check_result(result, op="create_branch")
|
argv, op="create_branch", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def checkout_branch(
|
def checkout_branch(
|
||||||
@ -536,8 +547,9 @@ class Git:
|
|||||||
GitCommandError: If the command failed.
|
GitCommandError: If the command failed.
|
||||||
"""
|
"""
|
||||||
argv = build_checkout(name)
|
argv = build_checkout(name)
|
||||||
result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = self._run_op(
|
||||||
_check_result(result, op="checkout_branch")
|
argv, op="checkout_branch", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def delete_branch(
|
def delete_branch(
|
||||||
@ -565,8 +577,9 @@ class Git:
|
|||||||
GitCommandError: If the command failed.
|
GitCommandError: If the command failed.
|
||||||
"""
|
"""
|
||||||
argv = build_delete_branch(name, force=force)
|
argv = build_delete_branch(name, force=force)
|
||||||
result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = self._run_op(
|
||||||
_check_result(result, op="delete_branch")
|
argv, op="delete_branch", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── Remotes ────────────────────────────────────────────────
|
# ── Remotes ────────────────────────────────────────────────
|
||||||
@ -598,8 +611,9 @@ class Git:
|
|||||||
GitCommandError: If the command failed.
|
GitCommandError: If the command failed.
|
||||||
"""
|
"""
|
||||||
argv = build_remote_add(name, url, fetch=fetch)
|
argv = build_remote_add(name, url, fetch=fetch)
|
||||||
result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = self._run_op(
|
||||||
_check_result(result, op="remote_add")
|
argv, op="remote_add", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def remote_get(
|
def remote_get(
|
||||||
@ -661,8 +675,7 @@ class Git:
|
|||||||
GitCommandError: If the command failed.
|
GitCommandError: If the command failed.
|
||||||
"""
|
"""
|
||||||
argv = build_reset(mode=mode, ref=ref, paths=paths)
|
argv = build_reset(mode=mode, ref=ref, paths=paths)
|
||||||
result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = self._run_op(argv, op="reset", cwd=cwd, envs=envs, timeout=timeout)
|
||||||
_check_result(result, op="reset")
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def restore(
|
def restore(
|
||||||
@ -694,8 +707,7 @@ class Git:
|
|||||||
GitCommandError: If the command failed.
|
GitCommandError: If the command failed.
|
||||||
"""
|
"""
|
||||||
argv = build_restore(paths, staged=staged, worktree=worktree, source=source)
|
argv = build_restore(paths, staged=staged, worktree=worktree, source=source)
|
||||||
result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = self._run_op(argv, op="restore", cwd=cwd, envs=envs, timeout=timeout)
|
||||||
_check_result(result, op="restore")
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── Configuration ──────────────────────────────────────────
|
# ── Configuration ──────────────────────────────────────────
|
||||||
@ -729,8 +741,9 @@ class Git:
|
|||||||
GitCommandError: If the command failed.
|
GitCommandError: If the command failed.
|
||||||
"""
|
"""
|
||||||
argv = build_config_set(key, value, scope=scope, repo_path=cwd)
|
argv = build_config_set(key, value, scope=scope, repo_path=cwd)
|
||||||
result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = self._run_op(
|
||||||
_check_result(result, op="set_config")
|
argv, op="set_config", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_config(
|
def get_config(
|
||||||
@ -957,6 +970,20 @@ class AsyncGit:
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _run_op(
|
||||||
|
self,
|
||||||
|
argv: list[str],
|
||||||
|
*,
|
||||||
|
op: str,
|
||||||
|
cwd: str | None = None,
|
||||||
|
envs: dict[str, str] | None = None,
|
||||||
|
timeout: int | None = 30,
|
||||||
|
) -> CommandResult:
|
||||||
|
"""``_run`` + :func:`_check_result` in one call. Raises on failure."""
|
||||||
|
result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
||||||
|
_check_result(result, op=op)
|
||||||
|
return result
|
||||||
|
|
||||||
# ── Repository setup ───────────────────────────────────────
|
# ── Repository setup ───────────────────────────────────────
|
||||||
|
|
||||||
async def clone(
|
async def clone(
|
||||||
@ -984,8 +1011,9 @@ class AsyncGit:
|
|||||||
clone_url = embed_credentials(url, username, password)
|
clone_url = embed_credentials(url, username, password)
|
||||||
|
|
||||||
argv = build_clone(clone_url, dest, branch=branch, depth=depth)
|
argv = build_clone(clone_url, dest, branch=branch, depth=depth)
|
||||||
result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = await self._run_op(
|
||||||
_check_result(result, op="clone")
|
argv, op="clone", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
|
|
||||||
if username and password and not dangerously_store_credentials:
|
if username and password and not dangerously_store_credentials:
|
||||||
sanitized = strip_credentials(clone_url)
|
sanitized = strip_credentials(clone_url)
|
||||||
@ -1014,8 +1042,9 @@ class AsyncGit:
|
|||||||
) -> CommandResult:
|
) -> CommandResult:
|
||||||
"""Initialize a new git repository."""
|
"""Initialize a new git repository."""
|
||||||
argv = build_init(path, bare=bare, initial_branch=initial_branch)
|
argv = build_init(path, bare=bare, initial_branch=initial_branch)
|
||||||
result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = await self._run_op(
|
||||||
_check_result(result, op="init")
|
argv, op="init", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── Staging and committing ─────────────────────────────────
|
# ── Staging and committing ─────────────────────────────────
|
||||||
@ -1031,8 +1060,7 @@ class AsyncGit:
|
|||||||
) -> CommandResult:
|
) -> CommandResult:
|
||||||
"""Stage files for commit."""
|
"""Stage files for commit."""
|
||||||
argv = build_add(paths, all=all)
|
argv = build_add(paths, all=all)
|
||||||
result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = await self._run_op(argv, op="add", cwd=cwd, envs=envs, timeout=timeout)
|
||||||
_check_result(result, op="add")
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def commit(
|
async def commit(
|
||||||
@ -1053,8 +1081,9 @@ class AsyncGit:
|
|||||||
author_name=author_name,
|
author_name=author_name,
|
||||||
author_email=author_email,
|
author_email=author_email,
|
||||||
)
|
)
|
||||||
result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = await self._run_op(
|
||||||
_check_result(result, op="commit")
|
argv, op="commit", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── Remote sync ────────────────────────────────────────────
|
# ── Remote sync ────────────────────────────────────────────
|
||||||
@ -1095,8 +1124,9 @@ class AsyncGit:
|
|||||||
)
|
)
|
||||||
|
|
||||||
argv = build_push(remote, branch, force=force, set_upstream=set_upstream)
|
argv = build_push(remote, branch, force=force, set_upstream=set_upstream)
|
||||||
result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = await self._run_op(
|
||||||
_check_result(result, op="push")
|
argv, op="push", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def pull(
|
async def pull(
|
||||||
@ -1135,8 +1165,9 @@ class AsyncGit:
|
|||||||
)
|
)
|
||||||
|
|
||||||
argv = build_pull(remote, branch, rebase=rebase, ff_only=ff_only)
|
argv = build_pull(remote, branch, rebase=rebase, ff_only=ff_only)
|
||||||
result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = await self._run_op(
|
||||||
_check_result(result, op="pull")
|
argv, op="pull", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── Status and branches ────────────────────────────────────
|
# ── Status and branches ────────────────────────────────────
|
||||||
@ -1149,8 +1180,9 @@ class AsyncGit:
|
|||||||
timeout: int | None = 30,
|
timeout: int | None = 30,
|
||||||
) -> GitStatus:
|
) -> GitStatus:
|
||||||
"""Get repository status."""
|
"""Get repository status."""
|
||||||
result = await self._run(build_status(), cwd=cwd, envs=envs, timeout=timeout)
|
result = await self._run_op(
|
||||||
_check_result(result, op="status")
|
build_status(), op="status", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return parse_status(result.stdout)
|
return parse_status(result.stdout)
|
||||||
|
|
||||||
async def branches(
|
async def branches(
|
||||||
@ -1161,8 +1193,9 @@ class AsyncGit:
|
|||||||
timeout: int | None = 30,
|
timeout: int | None = 30,
|
||||||
) -> list[GitBranch]:
|
) -> list[GitBranch]:
|
||||||
"""List local branches."""
|
"""List local branches."""
|
||||||
result = await self._run(build_branches(), cwd=cwd, envs=envs, timeout=timeout)
|
result = await self._run_op(
|
||||||
_check_result(result, op="branches")
|
build_branches(), op="branches", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return parse_branches(result.stdout)
|
return parse_branches(result.stdout)
|
||||||
|
|
||||||
async def create_branch(
|
async def create_branch(
|
||||||
@ -1176,8 +1209,9 @@ class AsyncGit:
|
|||||||
) -> CommandResult:
|
) -> CommandResult:
|
||||||
"""Create and check out a new branch."""
|
"""Create and check out a new branch."""
|
||||||
argv = build_create_branch(name, start_point=start_point)
|
argv = build_create_branch(name, start_point=start_point)
|
||||||
result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = await self._run_op(
|
||||||
_check_result(result, op="create_branch")
|
argv, op="create_branch", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def checkout_branch(
|
async def checkout_branch(
|
||||||
@ -1190,8 +1224,9 @@ class AsyncGit:
|
|||||||
) -> CommandResult:
|
) -> CommandResult:
|
||||||
"""Check out an existing branch."""
|
"""Check out an existing branch."""
|
||||||
argv = build_checkout(name)
|
argv = build_checkout(name)
|
||||||
result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = await self._run_op(
|
||||||
_check_result(result, op="checkout_branch")
|
argv, op="checkout_branch", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def delete_branch(
|
async def delete_branch(
|
||||||
@ -1205,8 +1240,9 @@ class AsyncGit:
|
|||||||
) -> CommandResult:
|
) -> CommandResult:
|
||||||
"""Delete a branch."""
|
"""Delete a branch."""
|
||||||
argv = build_delete_branch(name, force=force)
|
argv = build_delete_branch(name, force=force)
|
||||||
result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = await self._run_op(
|
||||||
_check_result(result, op="delete_branch")
|
argv, op="delete_branch", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── Remotes ────────────────────────────────────────────────
|
# ── Remotes ────────────────────────────────────────────────
|
||||||
@ -1223,8 +1259,9 @@ class AsyncGit:
|
|||||||
) -> CommandResult:
|
) -> CommandResult:
|
||||||
"""Add a remote."""
|
"""Add a remote."""
|
||||||
argv = build_remote_add(name, url, fetch=fetch)
|
argv = build_remote_add(name, url, fetch=fetch)
|
||||||
result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = await self._run_op(
|
||||||
_check_result(result, op="remote_add")
|
argv, op="remote_add", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def remote_get(
|
async def remote_get(
|
||||||
@ -1258,8 +1295,9 @@ class AsyncGit:
|
|||||||
) -> CommandResult:
|
) -> CommandResult:
|
||||||
"""Reset the current HEAD."""
|
"""Reset the current HEAD."""
|
||||||
argv = build_reset(mode=mode, ref=ref, paths=paths)
|
argv = build_reset(mode=mode, ref=ref, paths=paths)
|
||||||
result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = await self._run_op(
|
||||||
_check_result(result, op="reset")
|
argv, op="reset", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def restore(
|
async def restore(
|
||||||
@ -1275,8 +1313,9 @@ class AsyncGit:
|
|||||||
) -> CommandResult:
|
) -> CommandResult:
|
||||||
"""Restore working-tree files or unstage changes."""
|
"""Restore working-tree files or unstage changes."""
|
||||||
argv = build_restore(paths, staged=staged, worktree=worktree, source=source)
|
argv = build_restore(paths, staged=staged, worktree=worktree, source=source)
|
||||||
result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = await self._run_op(
|
||||||
_check_result(result, op="restore")
|
argv, op="restore", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── Configuration ──────────────────────────────────────────
|
# ── Configuration ──────────────────────────────────────────
|
||||||
@ -1293,8 +1332,9 @@ class AsyncGit:
|
|||||||
) -> CommandResult:
|
) -> CommandResult:
|
||||||
"""Set a git config value."""
|
"""Set a git config value."""
|
||||||
argv = build_config_set(key, value, scope=scope, repo_path=cwd)
|
argv = build_config_set(key, value, scope=scope, repo_path=cwd)
|
||||||
result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout)
|
result = await self._run_op(
|
||||||
_check_result(result, op="set_config")
|
argv, op="set_config", cwd=cwd, envs=envs, timeout=timeout
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def get_config(
|
async def get_config(
|
||||||
|
|||||||
@ -351,11 +351,6 @@ def build_config_get(
|
|||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
def build_has_upstream() -> list[str]:
|
|
||||||
"""Build arguments to check if current branch has upstream tracking."""
|
|
||||||
return ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Parsers ────────────────────────────────────────────────────────
|
# ── Parsers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import builtins
|
import builtins
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@ -10,15 +10,54 @@ from contextlib import asynccontextmanager
|
|||||||
import httpx_ws
|
import httpx_ws
|
||||||
|
|
||||||
from wrenn._git import AsyncGit
|
from wrenn._git import AsyncGit
|
||||||
from wrenn.capsule import _DualMethod, _build_proxy_url
|
from wrenn.capsule import (
|
||||||
|
_DEFAULT_WAIT_TIMEOUT,
|
||||||
|
_DESTROY_INTERVAL,
|
||||||
|
_FAIL_STATUSES,
|
||||||
|
_PAUSE_INTERVAL,
|
||||||
|
_RESUME_INTERVAL,
|
||||||
|
_START_INTERVAL,
|
||||||
|
_DualMethod,
|
||||||
|
_build_http_proxy_url,
|
||||||
|
)
|
||||||
from wrenn.client import AsyncWrennClient
|
from wrenn.client import AsyncWrennClient
|
||||||
from wrenn.commands import AsyncCommands
|
from wrenn.commands import AsyncCommands
|
||||||
|
from wrenn.exceptions import WrennNotFoundError
|
||||||
from wrenn.files import AsyncFiles
|
from wrenn.files import AsyncFiles
|
||||||
from wrenn.models import Capsule as CapsuleModel
|
from wrenn.models import Capsule as CapsuleModel
|
||||||
from wrenn.models import Status, Template
|
from wrenn.models import Status, Template
|
||||||
from wrenn.pty import AsyncPtySession
|
from wrenn.pty import AsyncPtySession
|
||||||
|
|
||||||
|
|
||||||
|
async def _apoll_until(
|
||||||
|
fetch,
|
||||||
|
targets: set[Status],
|
||||||
|
interval: float,
|
||||||
|
timeout: float = _DEFAULT_WAIT_TIMEOUT,
|
||||||
|
fail_on: set[Status] | None = None,
|
||||||
|
) -> CapsuleModel:
|
||||||
|
fail = fail_on if fail_on is not None else _FAIL_STATUSES
|
||||||
|
treat_missing_as_target = Status.missing in targets
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
last: CapsuleModel | None = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
last = await fetch()
|
||||||
|
except WrennNotFoundError:
|
||||||
|
if treat_missing_as_target:
|
||||||
|
return CapsuleModel(status=Status.missing)
|
||||||
|
raise
|
||||||
|
if last.status in targets:
|
||||||
|
return last
|
||||||
|
if last.status is not None and last.status in fail:
|
||||||
|
raise RuntimeError(f"Capsule entered {last.status} state while waiting")
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Capsule did not reach {targets} within {timeout}s "
|
||||||
|
f"(last status: {last.status if last else 'unknown'})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AsyncCapsule:
|
class AsyncCapsule:
|
||||||
"""Async Wrenn capsule with e2b-compatible interface.
|
"""Async Wrenn capsule with e2b-compatible interface.
|
||||||
|
|
||||||
@ -98,21 +137,26 @@ class AsyncCapsule:
|
|||||||
AsyncCapsule: A new capsule instance.
|
AsyncCapsule: A new capsule instance.
|
||||||
"""
|
"""
|
||||||
client = AsyncWrennClient(api_key=api_key, base_url=base_url)
|
client = AsyncWrennClient(api_key=api_key, base_url=base_url)
|
||||||
info = await client.capsules.create(
|
try:
|
||||||
template=template,
|
info = await client.capsules.create(
|
||||||
vcpus=vcpus,
|
template=template,
|
||||||
memory_mb=memory_mb,
|
vcpus=vcpus,
|
||||||
timeout_sec=timeout,
|
memory_mb=memory_mb,
|
||||||
)
|
timeout_sec=timeout,
|
||||||
assert info.id is not None
|
)
|
||||||
capsule = cls(
|
if info.id is None:
|
||||||
_capsule_id=info.id,
|
raise RuntimeError("API returned a capsule without an ID")
|
||||||
_client=client,
|
capsule = cls(
|
||||||
_info=info,
|
_capsule_id=info.id,
|
||||||
)
|
_client=client,
|
||||||
if wait:
|
_info=info,
|
||||||
await capsule.wait_ready()
|
)
|
||||||
return capsule
|
if wait:
|
||||||
|
await capsule.wait_ready()
|
||||||
|
return capsule
|
||||||
|
except BaseException:
|
||||||
|
await client.aclose()
|
||||||
|
raise
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def connect(
|
async def connect(
|
||||||
@ -137,16 +181,26 @@ class AsyncCapsule:
|
|||||||
WrennNotFoundError: If no capsule with the given ID exists.
|
WrennNotFoundError: If no capsule with the given ID exists.
|
||||||
"""
|
"""
|
||||||
client = AsyncWrennClient(api_key=api_key, base_url=base_url)
|
client = AsyncWrennClient(api_key=api_key, base_url=base_url)
|
||||||
info = await client.capsules.get(capsule_id)
|
try:
|
||||||
|
info = await client.capsules.get(capsule_id)
|
||||||
|
|
||||||
if info.status == Status.paused:
|
capsule = cls(
|
||||||
info = await client.capsules.resume(capsule_id)
|
_capsule_id=capsule_id,
|
||||||
|
_client=client,
|
||||||
|
_info=info,
|
||||||
|
)
|
||||||
|
|
||||||
return cls(
|
if info.status == Status.pausing:
|
||||||
_capsule_id=capsule_id,
|
info = await capsule._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
|
||||||
_client=client,
|
if info.status == Status.paused:
|
||||||
_info=info,
|
await client.capsules.resume(capsule_id)
|
||||||
)
|
if info.status != Status.running:
|
||||||
|
await capsule.wait_ready()
|
||||||
|
|
||||||
|
return capsule
|
||||||
|
except BaseException:
|
||||||
|
await client.aclose()
|
||||||
|
raise
|
||||||
|
|
||||||
# ── Dual instance/static lifecycle ──────────────────────────
|
# ── Dual instance/static lifecycle ──────────────────────────
|
||||||
|
|
||||||
@ -155,22 +209,35 @@ class AsyncCapsule:
|
|||||||
resume = _DualMethod("_instance_resume", "_static_resume")
|
resume = _DualMethod("_instance_resume", "_static_resume")
|
||||||
get_info = _DualMethod("_instance_get_info", "_static_get_info")
|
get_info = _DualMethod("_instance_get_info", "_static_get_info")
|
||||||
|
|
||||||
async def _instance_destroy(self) -> None:
|
async def _instance_destroy(self, wait: bool = False) -> None:
|
||||||
await self._client.capsules.destroy(self._id)
|
await self._client.capsules.destroy(self._id)
|
||||||
|
if wait:
|
||||||
|
await self._wait_for_status(
|
||||||
|
{Status.stopped, Status.missing}, _DESTROY_INTERVAL
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _static_destroy(
|
async def _static_destroy(
|
||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
await client.capsules.destroy(capsule_id)
|
await client.capsules.destroy(capsule_id)
|
||||||
|
if wait:
|
||||||
|
await _apoll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.stopped, Status.missing},
|
||||||
|
_DESTROY_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
async def _instance_pause(self) -> CapsuleModel:
|
async def _instance_pause(self, wait: bool = False) -> CapsuleModel:
|
||||||
self._info = await self._client.capsules.pause(self._id)
|
self._info = await self._client.capsules.pause(self._id)
|
||||||
|
if wait:
|
||||||
|
self._info = await self._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
|
||||||
return self._info
|
return self._info
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -178,14 +245,24 @@ class AsyncCapsule:
|
|||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> CapsuleModel:
|
) -> CapsuleModel:
|
||||||
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
return await client.capsules.pause(capsule_id)
|
info = await client.capsules.pause(capsule_id)
|
||||||
|
if wait:
|
||||||
|
info = await _apoll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.paused},
|
||||||
|
_PAUSE_INTERVAL,
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
async def _instance_resume(self) -> CapsuleModel:
|
async def _instance_resume(self, wait: bool = False) -> CapsuleModel:
|
||||||
self._info = await self._client.capsules.resume(self._id)
|
self._info = await self._client.capsules.resume(self._id)
|
||||||
|
if wait:
|
||||||
|
self._info = await self._wait_for_status({Status.running}, _RESUME_INTERVAL)
|
||||||
return self._info
|
return self._info
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -193,11 +270,19 @@ class AsyncCapsule:
|
|||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> CapsuleModel:
|
) -> CapsuleModel:
|
||||||
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
return await client.capsules.resume(capsule_id)
|
info = await client.capsules.resume(capsule_id)
|
||||||
|
if wait:
|
||||||
|
info = await _apoll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.running},
|
||||||
|
_RESUME_INTERVAL,
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
async def _instance_get_info(self) -> CapsuleModel:
|
async def _instance_get_info(self) -> CapsuleModel:
|
||||||
self._info = await self._client.capsules.get(self._id)
|
self._info = await self._client.capsules.get(self._id)
|
||||||
@ -224,31 +309,30 @@ class AsyncCapsule:
|
|||||||
"""
|
"""
|
||||||
await self._client.capsules.ping(self._id)
|
await self._client.capsules.ping(self._id)
|
||||||
|
|
||||||
async def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None:
|
async def _wait_for_status(
|
||||||
"""Await until the capsule status is ``running``.
|
self,
|
||||||
|
targets: set[Status],
|
||||||
|
interval: float,
|
||||||
|
timeout: float = _DEFAULT_WAIT_TIMEOUT,
|
||||||
|
) -> CapsuleModel:
|
||||||
|
info = await _apoll_until(
|
||||||
|
lambda: self._client.capsules.get(self._id),
|
||||||
|
targets,
|
||||||
|
interval,
|
||||||
|
timeout,
|
||||||
|
fail_on={Status.error, Status.stopped, Status.missing} - targets,
|
||||||
|
)
|
||||||
|
self._info = info
|
||||||
|
return info
|
||||||
|
|
||||||
Args:
|
async def wait_ready(self, timeout: float = _DEFAULT_WAIT_TIMEOUT) -> None:
|
||||||
timeout (float): Maximum seconds to wait. Defaults to ``30``.
|
"""Await until capsule status is ``running``.
|
||||||
interval (float): Polling interval in seconds. Defaults to ``0.5``.
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TimeoutError: If the capsule does not reach ``running`` state
|
TimeoutError: If capsule does not reach ``running`` within ``timeout``.
|
||||||
within ``timeout`` seconds.
|
RuntimeError: If capsule enters error/stopped/missing while waiting.
|
||||||
RuntimeError: If the capsule enters an error, stopped, or paused
|
|
||||||
state while waiting.
|
|
||||||
"""
|
"""
|
||||||
deadline = time.monotonic() + timeout
|
await self._wait_for_status({Status.running}, _START_INTERVAL, timeout)
|
||||||
while time.monotonic() < deadline:
|
|
||||||
info = await self._client.capsules.get(self._id)
|
|
||||||
if info.status == Status.running:
|
|
||||||
self._info = info
|
|
||||||
return
|
|
||||||
if info.status in (Status.error, Status.stopped):
|
|
||||||
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
|
||||||
if info.status == Status.paused:
|
|
||||||
info = await self._client.capsules.resume(self._id)
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
|
||||||
|
|
||||||
async def is_running(self) -> bool:
|
async def is_running(self) -> bool:
|
||||||
"""Check whether the capsule is currently running.
|
"""Check whether the capsule is currently running.
|
||||||
@ -348,16 +432,23 @@ class AsyncCapsule:
|
|||||||
# ── Proxy helpers ───────────────────────────────────────────
|
# ── Proxy helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
def get_url(self, port: int) -> str:
|
def get_url(self, port: int) -> str:
|
||||||
"""Get the proxy URL for a port exposed inside this capsule.
|
"""Get the HTTP proxy URL for a port exposed inside this capsule.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
port (int): Port number to proxy.
|
port (int): Port number to proxy.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: A ``wss://`` (or ``ws://``) URL that proxies to the given
|
str: A ``https://`` (or ``http://``) URL that proxies HTTP
|
||||||
port inside the capsule.
|
requests to the given port inside the capsule. For raw
|
||||||
|
WebSocket access, see the lower-level ``_build_proxy_url``
|
||||||
|
helper or the ``pty()`` API.
|
||||||
"""
|
"""
|
||||||
return _build_proxy_url(self._client._base_url, self._id, port)
|
return _build_http_proxy_url(
|
||||||
|
self._client._base_url,
|
||||||
|
self._id,
|
||||||
|
port,
|
||||||
|
self._client._proxy_domain,
|
||||||
|
)
|
||||||
|
|
||||||
# ── Snapshots ───────────────────────────────────────────────
|
# ── Snapshots ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import builtins
|
import builtins
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
@ -13,21 +13,94 @@ import httpx_ws
|
|||||||
from wrenn._git import Git
|
from wrenn._git import Git
|
||||||
from wrenn.client import WrennClient
|
from wrenn.client import WrennClient
|
||||||
from wrenn.commands import Commands
|
from wrenn.commands import Commands
|
||||||
|
from wrenn.exceptions import WrennNotFoundError
|
||||||
from wrenn.files import Files
|
from wrenn.files import Files
|
||||||
from wrenn.models import Capsule as CapsuleModel
|
from wrenn.models import Capsule as CapsuleModel
|
||||||
from wrenn.models import Status, Template
|
from wrenn.models import Status, Template
|
||||||
from wrenn.pty import PtySession
|
from wrenn.pty import PtySession
|
||||||
|
|
||||||
|
|
||||||
def _build_proxy_url(base_url: str, capsule_id: str | None, port: int) -> str:
|
def _proxy_url(
|
||||||
|
base_url: str,
|
||||||
|
capsule_id: str | None,
|
||||||
|
port: int,
|
||||||
|
proxy_domain: str | None,
|
||||||
|
*,
|
||||||
|
websocket: bool,
|
||||||
|
) -> str:
|
||||||
parsed = httpx.URL(base_url)
|
parsed = httpx.URL(base_url)
|
||||||
host = parsed.host
|
if proxy_domain:
|
||||||
if parsed.port:
|
host = proxy_domain
|
||||||
host = f"{host}:{parsed.port}"
|
else:
|
||||||
scheme = "ws" if parsed.scheme == "http" else "wss"
|
host = parsed.host
|
||||||
|
if parsed.port:
|
||||||
|
host = f"{host}:{parsed.port}"
|
||||||
|
secure = parsed.scheme not in ("http", "ws")
|
||||||
|
if websocket:
|
||||||
|
scheme = "wss" if secure else "ws"
|
||||||
|
else:
|
||||||
|
scheme = "https" if secure else "http"
|
||||||
return f"{scheme}://{port}-{capsule_id}.{host}"
|
return f"{scheme}://{port}-{capsule_id}.{host}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_proxy_url(
|
||||||
|
base_url: str,
|
||||||
|
capsule_id: str | None,
|
||||||
|
port: int,
|
||||||
|
proxy_domain: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build the WebSocket proxy URL (``ws://`` / ``wss://``)."""
|
||||||
|
return _proxy_url(base_url, capsule_id, port, proxy_domain, websocket=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_http_proxy_url(
|
||||||
|
base_url: str,
|
||||||
|
capsule_id: str | None,
|
||||||
|
port: int,
|
||||||
|
proxy_domain: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build the HTTP proxy URL (``http://`` / ``https://``)."""
|
||||||
|
return _proxy_url(base_url, capsule_id, port, proxy_domain, websocket=False)
|
||||||
|
|
||||||
|
|
||||||
|
_RESUME_INTERVAL = 0.5
|
||||||
|
_DESTROY_INTERVAL = 0.5
|
||||||
|
_PAUSE_INTERVAL = 2.0
|
||||||
|
_START_INTERVAL = 0.5
|
||||||
|
_DEFAULT_WAIT_TIMEOUT = 30.0
|
||||||
|
_FAIL_STATUSES = {Status.error}
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_until(
|
||||||
|
fetch,
|
||||||
|
targets: set[Status],
|
||||||
|
interval: float,
|
||||||
|
timeout: float = _DEFAULT_WAIT_TIMEOUT,
|
||||||
|
fail_on: set[Status] | None = None,
|
||||||
|
) -> CapsuleModel:
|
||||||
|
"""Poll ``fetch()`` until status ∈ ``targets``. Raise on ``fail_on``/timeout."""
|
||||||
|
fail = fail_on if fail_on is not None else _FAIL_STATUSES
|
||||||
|
treat_missing_as_target = Status.missing in targets
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
last: CapsuleModel | None = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
last = fetch()
|
||||||
|
except WrennNotFoundError:
|
||||||
|
if treat_missing_as_target:
|
||||||
|
return CapsuleModel(status=Status.missing)
|
||||||
|
raise
|
||||||
|
if last.status in targets:
|
||||||
|
return last
|
||||||
|
if last.status is not None and last.status in fail:
|
||||||
|
raise RuntimeError(f"Capsule entered {last.status} state while waiting")
|
||||||
|
time.sleep(interval)
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Capsule did not reach {targets} within {timeout}s "
|
||||||
|
f"(last status: {last.status if last else 'unknown'})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _DualMethod:
|
class _DualMethod:
|
||||||
"""Descriptor that dispatches to instance method or classmethod depending on call site."""
|
"""Descriptor that dispatches to instance method or classmethod depending on call site."""
|
||||||
|
|
||||||
@ -100,9 +173,6 @@ class Capsule:
|
|||||||
self._id: str = _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:
|
||||||
self._client = WrennClient(api_key=api_key, base_url=base_url)
|
self._client = WrennClient(api_key=api_key, base_url=base_url)
|
||||||
try:
|
try:
|
||||||
@ -112,9 +182,9 @@ class Capsule:
|
|||||||
memory_mb=memory_mb,
|
memory_mb=memory_mb,
|
||||||
timeout_sec=timeout,
|
timeout_sec=timeout,
|
||||||
)
|
)
|
||||||
self._id = self._info.id
|
if self._info.id is None:
|
||||||
if self._id is None:
|
|
||||||
raise RuntimeError("API returned a capsule without an ID")
|
raise RuntimeError("API returned a capsule without an ID")
|
||||||
|
self._id = self._info.id
|
||||||
except Exception:
|
except Exception:
|
||||||
self._client.close()
|
self._client.close()
|
||||||
raise
|
raise
|
||||||
@ -213,15 +283,21 @@ class Capsule:
|
|||||||
client = WrennClient(api_key=api_key, base_url=base_url)
|
client = WrennClient(api_key=api_key, base_url=base_url)
|
||||||
info = client.capsules.get(capsule_id)
|
info = client.capsules.get(capsule_id)
|
||||||
|
|
||||||
if info.status == Status.paused:
|
capsule = cls(
|
||||||
info = client.capsules.resume(capsule_id)
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
_capsule_id=capsule_id,
|
_capsule_id=capsule_id,
|
||||||
_client=client,
|
_client=client,
|
||||||
_info=info,
|
_info=info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if info.status == Status.pausing:
|
||||||
|
info = capsule._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
|
||||||
|
if info.status == Status.paused:
|
||||||
|
client.capsules.resume(capsule_id)
|
||||||
|
if info.status != Status.running:
|
||||||
|
capsule.wait_ready()
|
||||||
|
|
||||||
|
return capsule
|
||||||
|
|
||||||
# ── Dual instance/static lifecycle ──────────────────────────
|
# ── Dual instance/static lifecycle ──────────────────────────
|
||||||
|
|
||||||
destroy = _DualMethod("_instance_destroy", "_static_destroy")
|
destroy = _DualMethod("_instance_destroy", "_static_destroy")
|
||||||
@ -229,25 +305,36 @@ class Capsule:
|
|||||||
resume = _DualMethod("_instance_resume", "_static_resume")
|
resume = _DualMethod("_instance_resume", "_static_resume")
|
||||||
get_info = _DualMethod("_instance_get_info", "_static_get_info")
|
get_info = _DualMethod("_instance_get_info", "_static_get_info")
|
||||||
|
|
||||||
def _instance_destroy(self) -> None:
|
def _instance_destroy(self, wait: bool = False) -> None:
|
||||||
"""Destroy this capsule."""
|
"""Destroy this capsule. If ``wait``, poll until stopped/missing."""
|
||||||
self._client.capsules.destroy(self._id)
|
self._client.capsules.destroy(self._id)
|
||||||
|
if wait:
|
||||||
|
self._wait_for_status({Status.stopped, Status.missing}, _DESTROY_INTERVAL)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _static_destroy(
|
def _static_destroy(
|
||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Destroy a capsule by ID."""
|
"""Destroy a capsule by ID."""
|
||||||
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
client.capsules.destroy(capsule_id)
|
client.capsules.destroy(capsule_id)
|
||||||
|
if wait:
|
||||||
|
_poll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.stopped, Status.missing},
|
||||||
|
_DESTROY_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
def _instance_pause(self) -> CapsuleModel:
|
def _instance_pause(self, wait: bool = False) -> CapsuleModel:
|
||||||
"""Pause this capsule."""
|
"""Pause this capsule. If ``wait``, poll until ``paused``."""
|
||||||
self._info = self._client.capsules.pause(self._id)
|
self._info = self._client.capsules.pause(self._id)
|
||||||
|
if wait:
|
||||||
|
self._info = self._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
|
||||||
return self._info
|
return self._info
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -255,16 +342,26 @@ class Capsule:
|
|||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> CapsuleModel:
|
) -> CapsuleModel:
|
||||||
"""Pause a capsule by ID."""
|
"""Pause a capsule by ID."""
|
||||||
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
return client.capsules.pause(capsule_id)
|
info = client.capsules.pause(capsule_id)
|
||||||
|
if wait:
|
||||||
|
info = _poll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.paused},
|
||||||
|
_PAUSE_INTERVAL,
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
def _instance_resume(self) -> CapsuleModel:
|
def _instance_resume(self, wait: bool = False) -> CapsuleModel:
|
||||||
"""Resume this capsule."""
|
"""Resume this capsule. If ``wait``, poll until ``running``."""
|
||||||
self._info = self._client.capsules.resume(self._id)
|
self._info = self._client.capsules.resume(self._id)
|
||||||
|
if wait:
|
||||||
|
self._info = self._wait_for_status({Status.running}, _RESUME_INTERVAL)
|
||||||
return self._info
|
return self._info
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -272,12 +369,20 @@ class Capsule:
|
|||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> CapsuleModel:
|
) -> CapsuleModel:
|
||||||
"""Resume a capsule by ID."""
|
"""Resume a capsule by ID."""
|
||||||
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
return client.capsules.resume(capsule_id)
|
info = client.capsules.resume(capsule_id)
|
||||||
|
if wait:
|
||||||
|
info = _poll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.running},
|
||||||
|
_RESUME_INTERVAL,
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
def _instance_get_info(self) -> CapsuleModel:
|
def _instance_get_info(self) -> CapsuleModel:
|
||||||
"""Get current info for this capsule."""
|
"""Get current info for this capsule."""
|
||||||
@ -306,31 +411,30 @@ class Capsule:
|
|||||||
"""
|
"""
|
||||||
self._client.capsules.ping(self._id)
|
self._client.capsules.ping(self._id)
|
||||||
|
|
||||||
def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None:
|
def _wait_for_status(
|
||||||
"""Block until the capsule status is ``running``.
|
self,
|
||||||
|
targets: set[Status],
|
||||||
|
interval: float,
|
||||||
|
timeout: float = _DEFAULT_WAIT_TIMEOUT,
|
||||||
|
) -> CapsuleModel:
|
||||||
|
info = _poll_until(
|
||||||
|
lambda: self._client.capsules.get(self._id),
|
||||||
|
targets,
|
||||||
|
interval,
|
||||||
|
timeout,
|
||||||
|
fail_on={Status.error, Status.stopped, Status.missing} - targets,
|
||||||
|
)
|
||||||
|
self._info = info
|
||||||
|
return info
|
||||||
|
|
||||||
Args:
|
def wait_ready(self, timeout: float = _DEFAULT_WAIT_TIMEOUT) -> None:
|
||||||
timeout (float): Maximum seconds to wait. Defaults to ``30``.
|
"""Block until capsule status is ``running``.
|
||||||
interval (float): Polling interval in seconds. Defaults to ``0.5``.
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TimeoutError: If the capsule does not reach ``running`` state
|
TimeoutError: If capsule does not reach ``running`` within ``timeout``.
|
||||||
within ``timeout`` seconds.
|
RuntimeError: If capsule enters error/stopped/missing while waiting.
|
||||||
RuntimeError: If the capsule enters an error, stopped, or paused
|
|
||||||
state while waiting.
|
|
||||||
"""
|
"""
|
||||||
deadline = time.monotonic() + timeout
|
self._wait_for_status({Status.running}, _START_INTERVAL, timeout)
|
||||||
while time.monotonic() < deadline:
|
|
||||||
info = self._client.capsules.get(self._id)
|
|
||||||
if info.status == Status.running:
|
|
||||||
self._info = info
|
|
||||||
return
|
|
||||||
if info.status in (Status.error, Status.stopped):
|
|
||||||
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
|
||||||
if info.status == Status.paused:
|
|
||||||
info = self._client.capsules.resume(self._id)
|
|
||||||
time.sleep(interval)
|
|
||||||
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
|
||||||
|
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""Check whether the capsule is currently running.
|
"""Check whether the capsule is currently running.
|
||||||
@ -429,16 +533,23 @@ class Capsule:
|
|||||||
# ── Proxy helpers ───────────────────────────────────────────
|
# ── Proxy helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
def get_url(self, port: int) -> str:
|
def get_url(self, port: int) -> str:
|
||||||
"""Get the proxy URL for a port exposed inside this capsule.
|
"""Get the HTTP proxy URL for a port exposed inside this capsule.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
port (int): Port number to proxy.
|
port (int): Port number to proxy.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: A ``wss://`` (or ``ws://``) URL that proxies to the given
|
str: A ``https://`` (or ``http://``) URL that proxies HTTP
|
||||||
port inside the capsule.
|
requests to the given port inside the capsule. For raw
|
||||||
|
WebSocket access, see the lower-level ``_build_proxy_url``
|
||||||
|
helper or the ``pty()`` API.
|
||||||
"""
|
"""
|
||||||
return _build_proxy_url(self._client._base_url, self._id, port)
|
return _build_http_proxy_url(
|
||||||
|
self._client._base_url,
|
||||||
|
self._id,
|
||||||
|
port,
|
||||||
|
self._client._proxy_domain,
|
||||||
|
)
|
||||||
|
|
||||||
# ── Snapshots ───────────────────────────────────────────────
|
# ── Snapshots ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from wrenn._config import DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL
|
from wrenn._config import (
|
||||||
|
DEFAULT_BASE_URL,
|
||||||
|
DEFAULT_PROXY_DOMAIN,
|
||||||
|
ENV_API_KEY,
|
||||||
|
ENV_BASE_URL,
|
||||||
|
ENV_PROXY_DOMAIN,
|
||||||
|
)
|
||||||
from wrenn.exceptions import handle_response
|
from wrenn.exceptions import handle_response
|
||||||
|
|
||||||
from wrenn.models import (
|
from wrenn.models import (
|
||||||
@ -15,6 +23,56 @@ from wrenn.models import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
_LONG_TIMEOUT = httpx.Timeout(60.0)
|
_LONG_TIMEOUT = httpx.Timeout(60.0)
|
||||||
|
_DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
||||||
|
|
||||||
|
_RETRY_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
||||||
|
httpx.ReadError,
|
||||||
|
httpx.RemoteProtocolError,
|
||||||
|
httpx.ConnectError,
|
||||||
|
httpx.ReadTimeout,
|
||||||
|
)
|
||||||
|
_RETRY_METHODS = frozenset({"GET", "HEAD", "DELETE", "OPTIONS", "PUT"})
|
||||||
|
_MAX_RETRIES = 3
|
||||||
|
_BACKOFF_BASE = 0.3
|
||||||
|
|
||||||
|
|
||||||
|
def _should_retry(request: httpx.Request, attempt: int) -> bool:
|
||||||
|
return attempt < _MAX_RETRIES - 1 and request.method.upper() in _RETRY_METHODS
|
||||||
|
|
||||||
|
|
||||||
|
def _backoff_delay(attempt: int) -> float:
|
||||||
|
return _BACKOFF_BASE * (2**attempt)
|
||||||
|
|
||||||
|
|
||||||
|
class _RetryingClient(httpx.Client):
|
||||||
|
"""httpx.Client that retries transient TLS/connection errors on
|
||||||
|
idempotent methods (GET/HEAD/DELETE/OPTIONS/PUT). Non-idempotent
|
||||||
|
requests (POST/PATCH) propagate immediately."""
|
||||||
|
|
||||||
|
def send(self, request: httpx.Request, **kwargs): # type: ignore[override]
|
||||||
|
for attempt in range(_MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
return super().send(request, **kwargs)
|
||||||
|
except _RETRY_EXCEPTIONS:
|
||||||
|
if not _should_retry(request, attempt):
|
||||||
|
raise
|
||||||
|
time.sleep(_backoff_delay(attempt))
|
||||||
|
# Unreachable: loop either returns or raises.
|
||||||
|
raise RuntimeError("retry loop exited without result")
|
||||||
|
|
||||||
|
|
||||||
|
class _RetryingAsyncClient(httpx.AsyncClient):
|
||||||
|
"""Async variant of :class:`_RetryingClient`."""
|
||||||
|
|
||||||
|
async def send(self, request: httpx.Request, **kwargs): # type: ignore[override]
|
||||||
|
for attempt in range(_MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
return await super().send(request, **kwargs)
|
||||||
|
except _RETRY_EXCEPTIONS:
|
||||||
|
if not _should_retry(request, attempt):
|
||||||
|
raise
|
||||||
|
await asyncio.sleep(_backoff_delay(attempt))
|
||||||
|
raise RuntimeError("retry loop exited without result")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_api_key(api_key: str | None) -> str:
|
def _resolve_api_key(api_key: str | None) -> str:
|
||||||
@ -26,6 +84,73 @@ def _resolve_api_key(api_key: str | None) -> str:
|
|||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_timeout(
|
||||||
|
timeout: httpx.Timeout | float | None,
|
||||||
|
) -> httpx.Timeout:
|
||||||
|
if timeout is None:
|
||||||
|
return _DEFAULT_TIMEOUT
|
||||||
|
if isinstance(timeout, httpx.Timeout):
|
||||||
|
return timeout
|
||||||
|
return httpx.Timeout(timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_proxy_domain(base_url: str, override: str | None) -> str:
|
||||||
|
"""Resolve proxy host suffix for ``{port}-{capsule_id}.<domain>`` URLs.
|
||||||
|
|
||||||
|
Precedence: explicit ``override`` arg, ``WRENN_PROXY_DOMAIN`` env, then
|
||||||
|
``wrenn.dev`` only when ``base_url`` is the default Wrenn host
|
||||||
|
(``app.wrenn.dev``). Otherwise the ``base_url`` host (with port) is used
|
||||||
|
verbatim — appropriate for local dev or custom deployments.
|
||||||
|
"""
|
||||||
|
resolved = override or os.environ.get(ENV_PROXY_DOMAIN)
|
||||||
|
if resolved:
|
||||||
|
return resolved
|
||||||
|
parsed = httpx.URL(base_url)
|
||||||
|
host = parsed.host
|
||||||
|
if host == "app.wrenn.dev":
|
||||||
|
return DEFAULT_PROXY_DOMAIN
|
||||||
|
if parsed.port:
|
||||||
|
return f"{host}:{parsed.port}"
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
def _build_capsule_create_payload(
|
||||||
|
template: str | None,
|
||||||
|
vcpus: int | None,
|
||||||
|
memory_mb: int | None,
|
||||||
|
timeout_sec: int | None,
|
||||||
|
) -> dict:
|
||||||
|
payload: dict = {}
|
||||||
|
if template is not None:
|
||||||
|
payload["template"] = template
|
||||||
|
if vcpus is not None:
|
||||||
|
payload["vcpus"] = vcpus
|
||||||
|
if memory_mb is not None:
|
||||||
|
payload["memory_mb"] = memory_mb
|
||||||
|
if timeout_sec is not None:
|
||||||
|
payload["timeout_sec"] = timeout_sec
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _build_snapshot_create(
|
||||||
|
capsule_id: str, name: str | None, overwrite: bool
|
||||||
|
) -> tuple[dict, dict]:
|
||||||
|
payload: dict = {"sandbox_id": capsule_id}
|
||||||
|
if name is not None:
|
||||||
|
payload["name"] = name
|
||||||
|
params: dict = {}
|
||||||
|
if overwrite:
|
||||||
|
params["overwrite"] = "true"
|
||||||
|
return payload, params
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_list_params(type: str | None) -> dict:
|
||||||
|
params: dict = {}
|
||||||
|
if type is not None:
|
||||||
|
params["type"] = type
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
class CapsulesResource:
|
class CapsulesResource:
|
||||||
"""Sync capsule control-plane operations."""
|
"""Sync capsule control-plane operations."""
|
||||||
|
|
||||||
@ -51,16 +176,10 @@ class CapsulesResource:
|
|||||||
Returns:
|
Returns:
|
||||||
CapsuleModel: The newly created capsule.
|
CapsuleModel: The newly created capsule.
|
||||||
"""
|
"""
|
||||||
payload: dict = {}
|
resp = self._http.post(
|
||||||
if template is not None:
|
"/v1/capsules",
|
||||||
payload["template"] = template
|
json=_build_capsule_create_payload(template, vcpus, memory_mb, timeout_sec),
|
||||||
if vcpus is not None:
|
)
|
||||||
payload["vcpus"] = vcpus
|
|
||||||
if memory_mb is not None:
|
|
||||||
payload["memory_mb"] = memory_mb
|
|
||||||
if timeout_sec is not None:
|
|
||||||
payload["timeout_sec"] = timeout_sec
|
|
||||||
resp = self._http.post("/v1/capsules", json=payload)
|
|
||||||
return CapsuleModel.model_validate(handle_response(resp))
|
return CapsuleModel.model_validate(handle_response(resp))
|
||||||
|
|
||||||
def list(self) -> list[CapsuleModel]:
|
def list(self) -> list[CapsuleModel]:
|
||||||
@ -111,7 +230,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", timeout=_LONG_TIMEOUT)
|
resp = self._http.post(f"/v1/capsules/{id}/pause")
|
||||||
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:
|
||||||
@ -167,16 +286,10 @@ class AsyncCapsulesResource:
|
|||||||
Returns:
|
Returns:
|
||||||
CapsuleModel: The newly created capsule.
|
CapsuleModel: The newly created capsule.
|
||||||
"""
|
"""
|
||||||
payload: dict = {}
|
resp = await self._http.post(
|
||||||
if template is not None:
|
"/v1/capsules",
|
||||||
payload["template"] = template
|
json=_build_capsule_create_payload(template, vcpus, memory_mb, timeout_sec),
|
||||||
if vcpus is not None:
|
)
|
||||||
payload["vcpus"] = vcpus
|
|
||||||
if memory_mb is not None:
|
|
||||||
payload["memory_mb"] = memory_mb
|
|
||||||
if timeout_sec is not None:
|
|
||||||
payload["timeout_sec"] = timeout_sec
|
|
||||||
resp = await self._http.post("/v1/capsules", json=payload)
|
|
||||||
return CapsuleModel.model_validate(handle_response(resp))
|
return CapsuleModel.model_validate(handle_response(resp))
|
||||||
|
|
||||||
async def list(self) -> list[CapsuleModel]:
|
async def list(self) -> list[CapsuleModel]:
|
||||||
@ -227,7 +340,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", timeout=_LONG_TIMEOUT)
|
resp = await self._http.post(f"/v1/capsules/{id}/pause")
|
||||||
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:
|
||||||
@ -282,12 +395,7 @@ class SnapshotsResource:
|
|||||||
Returns:
|
Returns:
|
||||||
Template: The created snapshot template.
|
Template: The created snapshot template.
|
||||||
"""
|
"""
|
||||||
payload: dict = {"sandbox_id": capsule_id}
|
payload, params = _build_snapshot_create(capsule_id, name, overwrite)
|
||||||
if name is not None:
|
|
||||||
payload["name"] = name
|
|
||||||
params: dict = {}
|
|
||||||
if overwrite:
|
|
||||||
params["overwrite"] = "true"
|
|
||||||
resp = self._http.post(
|
resp = self._http.post(
|
||||||
"/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT
|
"/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT
|
||||||
)
|
)
|
||||||
@ -303,10 +411,7 @@ class SnapshotsResource:
|
|||||||
Returns:
|
Returns:
|
||||||
list[Template]: Matching snapshot templates.
|
list[Template]: Matching snapshot templates.
|
||||||
"""
|
"""
|
||||||
params: dict = {}
|
resp = self._http.get("/v1/snapshots", params=_snapshot_list_params(type))
|
||||||
if type is not None:
|
|
||||||
params["type"] = type
|
|
||||||
resp = self._http.get("/v1/snapshots", params=params)
|
|
||||||
return [Template.model_validate(item) for item in handle_response(resp)]
|
return [Template.model_validate(item) for item in handle_response(resp)]
|
||||||
|
|
||||||
def delete(self, name: str) -> None:
|
def delete(self, name: str) -> None:
|
||||||
@ -346,12 +451,7 @@ class AsyncSnapshotsResource:
|
|||||||
Returns:
|
Returns:
|
||||||
Template: The created snapshot template.
|
Template: The created snapshot template.
|
||||||
"""
|
"""
|
||||||
payload: dict = {"sandbox_id": capsule_id}
|
payload, params = _build_snapshot_create(capsule_id, name, overwrite)
|
||||||
if name is not None:
|
|
||||||
payload["name"] = name
|
|
||||||
params: dict = {}
|
|
||||||
if overwrite:
|
|
||||||
params["overwrite"] = "true"
|
|
||||||
resp = await self._http.post(
|
resp = await self._http.post(
|
||||||
"/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT
|
"/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT
|
||||||
)
|
)
|
||||||
@ -367,10 +467,7 @@ class AsyncSnapshotsResource:
|
|||||||
Returns:
|
Returns:
|
||||||
list[Template]: Matching snapshot templates.
|
list[Template]: Matching snapshot templates.
|
||||||
"""
|
"""
|
||||||
params: dict = {}
|
resp = await self._http.get("/v1/snapshots", params=_snapshot_list_params(type))
|
||||||
if type is not None:
|
|
||||||
params["type"] = type
|
|
||||||
resp = await self._http.get("/v1/snapshots", params=params)
|
|
||||||
return [Template.model_validate(item) for item in handle_response(resp)]
|
return [Template.model_validate(item) for item in handle_response(resp)]
|
||||||
|
|
||||||
async def delete(self, name: str) -> None:
|
async def delete(self, name: str) -> None:
|
||||||
@ -393,19 +490,29 @@ class WrennClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var.
|
api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var.
|
||||||
base_url: Wrenn API base URL.
|
base_url: Wrenn API base URL. Falls back to ``WRENN_BASE_URL`` env var.
|
||||||
|
proxy_domain: Host suffix for capsule proxy URLs
|
||||||
|
(``{port}-{capsule_id}.<domain>``). Falls back to
|
||||||
|
``WRENN_PROXY_DOMAIN`` env, then ``wrenn.dev`` when ``base_url``
|
||||||
|
is the default ``app.wrenn.dev`` host, else the ``base_url`` host.
|
||||||
|
timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
|
||||||
|
or ``None`` for the default (30s read/write/pool, 10s connect).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
|
proxy_domain: str | None = None,
|
||||||
|
timeout: httpx.Timeout | float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._api_key = _resolve_api_key(api_key)
|
self._api_key = _resolve_api_key(api_key)
|
||||||
self._base_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL)
|
self._base_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL)
|
||||||
self._http = httpx.Client(
|
self._proxy_domain = _resolve_proxy_domain(self._base_url, proxy_domain)
|
||||||
|
self._http = _RetryingClient(
|
||||||
base_url=self._base_url,
|
base_url=self._base_url,
|
||||||
headers={"X-API-Key": self._api_key},
|
headers={"X-API-Key": self._api_key},
|
||||||
|
timeout=_resolve_timeout(timeout),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.capsules = CapsulesResource(self._http)
|
self.capsules = CapsulesResource(self._http)
|
||||||
@ -440,18 +547,28 @@ class AsyncWrennClient:
|
|||||||
Args:
|
Args:
|
||||||
api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var.
|
api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var.
|
||||||
base_url: Wrenn API base URL. Falls back to ``WRENN_BASE_URL`` env var.
|
base_url: Wrenn API base URL. Falls back to ``WRENN_BASE_URL`` env var.
|
||||||
|
proxy_domain: Host suffix for capsule proxy URLs
|
||||||
|
(``{port}-{capsule_id}.<domain>``). Falls back to
|
||||||
|
``WRENN_PROXY_DOMAIN`` env, then ``wrenn.dev`` when ``base_url``
|
||||||
|
is the default ``app.wrenn.dev`` host, else the ``base_url`` host.
|
||||||
|
timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
|
||||||
|
or ``None`` for the default (30s read/write/pool, 10s connect).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
|
proxy_domain: str | None = None,
|
||||||
|
timeout: httpx.Timeout | float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._api_key = _resolve_api_key(api_key)
|
self._api_key = _resolve_api_key(api_key)
|
||||||
self._base_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL)
|
self._base_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL)
|
||||||
self._http = httpx.AsyncClient(
|
self._proxy_domain = _resolve_proxy_domain(self._base_url, proxy_domain)
|
||||||
|
self._http = _RetryingAsyncClient(
|
||||||
base_url=self._base_url,
|
base_url=self._base_url,
|
||||||
headers={"X-API-Key": self._api_key},
|
headers={"X-API-Key": self._api_key},
|
||||||
|
timeout=_resolve_timeout(timeout),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.capsules = AsyncCapsulesResource(self._http)
|
self.capsules = AsyncCapsulesResource(self._http)
|
||||||
|
|||||||
@ -1,6 +1,33 @@
|
|||||||
from wrenn.code_interpreter.async_capsule import AsyncCapsule
|
"""Deprecated alias for :mod:`wrenn.code_runner`.
|
||||||
from wrenn.code_interpreter.capsule import Capsule
|
|
||||||
from wrenn.code_interpreter.models import (
|
Importing from ``wrenn.code_interpreter`` emits a ``FutureWarning``.
|
||||||
|
Use ``wrenn.code_runner`` instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import warnings as _warnings
|
||||||
|
|
||||||
|
warnings_emitted: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_once() -> None:
|
||||||
|
global warnings_emitted
|
||||||
|
if warnings_emitted:
|
||||||
|
return
|
||||||
|
warnings_emitted = True
|
||||||
|
_warnings.warn(
|
||||||
|
"'wrenn.code_interpreter' is deprecated, use 'wrenn.code_runner' instead",
|
||||||
|
FutureWarning,
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_warn_once()
|
||||||
|
|
||||||
|
from wrenn.code_runner.async_capsule import AsyncCapsule # noqa: E402
|
||||||
|
from wrenn.code_runner.capsule import Capsule # noqa: E402
|
||||||
|
from wrenn.code_runner.models import ( # noqa: E402
|
||||||
Execution,
|
Execution,
|
||||||
ExecutionError,
|
ExecutionError,
|
||||||
Logs,
|
Logs,
|
||||||
@ -20,12 +47,11 @@ __all__ = [
|
|||||||
|
|
||||||
def __getattr__(name: str) -> type:
|
def __getattr__(name: str) -> type:
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
|
||||||
|
|
||||||
_module = sys.modules[__name__]
|
_module = sys.modules[__name__]
|
||||||
|
|
||||||
if name == "Sandbox":
|
if name == "Sandbox":
|
||||||
warnings.warn(
|
_warnings.warn(
|
||||||
"'Sandbox' is deprecated, use 'Capsule' instead",
|
"'Sandbox' is deprecated, use 'Capsule' instead",
|
||||||
FutureWarning,
|
FutureWarning,
|
||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
|
|||||||
@ -1,292 +1,3 @@
|
|||||||
from __future__ import annotations
|
"""Deprecated — use :mod:`wrenn.code_runner.async_capsule`."""
|
||||||
|
|
||||||
import asyncio
|
from wrenn.code_runner.async_capsule import AsyncCapsule # noqa: F401
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import httpx_ws
|
|
||||||
|
|
||||||
from wrenn.async_capsule import AsyncCapsule as BaseAsyncCapsule
|
|
||||||
from wrenn.capsule import _build_proxy_url
|
|
||||||
from wrenn.client import AsyncWrennClient
|
|
||||||
from wrenn.code_interpreter.capsule import DEFAULT_TEMPLATE
|
|
||||||
from wrenn.code_interpreter.models import (
|
|
||||||
Execution,
|
|
||||||
ExecutionError,
|
|
||||||
Result,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AsyncCapsule(BaseAsyncCapsule):
|
|
||||||
"""Async code interpreter capsule with ``run_code`` support.
|
|
||||||
|
|
||||||
Uses ``code-runner-beta`` template by default::
|
|
||||||
|
|
||||||
from wrenn.code_interpreter import AsyncCapsule
|
|
||||||
|
|
||||||
capsule = await AsyncCapsule.create()
|
|
||||||
result = await capsule.run_code("print('hello')")
|
|
||||||
"""
|
|
||||||
|
|
||||||
_kernel_id: str | None
|
|
||||||
_proxy_client: httpx.AsyncClient | None
|
|
||||||
|
|
||||||
def __init__(self, **kwargs) -> None:
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._kernel_id = None
|
|
||||||
self._proxy_client = None
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
if self._proxy_client is not None:
|
|
||||||
try:
|
|
||||||
await self._proxy_client.aclose()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._proxy_client = None
|
|
||||||
|
|
||||||
def __del__(self) -> None:
|
|
||||||
if self._proxy_client is not None:
|
|
||||||
try:
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
if loop.is_running():
|
|
||||||
loop.create_task(self._proxy_client.aclose())
|
|
||||||
else:
|
|
||||||
loop.run_until_complete(self._proxy_client.aclose())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._proxy_client = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def create(
|
|
||||||
cls,
|
|
||||||
template: str | None = None,
|
|
||||||
vcpus: int | None = None,
|
|
||||||
memory_mb: int | None = None,
|
|
||||||
timeout: int | None = None,
|
|
||||||
*,
|
|
||||||
wait: bool = False,
|
|
||||||
api_key: str | None = None,
|
|
||||||
base_url: str | None = None,
|
|
||||||
) -> AsyncCapsule:
|
|
||||||
"""Create a new async code interpreter capsule.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template (str | None): Template to boot from. Defaults to
|
|
||||||
``"code-runner-beta"``.
|
|
||||||
vcpus (int | None): Number of virtual CPUs.
|
|
||||||
memory_mb (int | None): Memory in MiB.
|
|
||||||
timeout (int | None): Inactivity TTL in seconds before auto-pause.
|
|
||||||
wait (bool): Await until the capsule reaches ``running`` status.
|
|
||||||
api_key (str | None): Wrenn API key. Falls back to
|
|
||||||
``WRENN_API_KEY`` env var.
|
|
||||||
base_url (str | None): API base URL override.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AsyncCapsule: A new async code interpreter capsule instance.
|
|
||||||
"""
|
|
||||||
client = AsyncWrennClient(api_key=api_key, base_url=base_url)
|
|
||||||
info = await client.capsules.create(
|
|
||||||
template=template or DEFAULT_TEMPLATE,
|
|
||||||
vcpus=vcpus,
|
|
||||||
memory_mb=memory_mb,
|
|
||||||
timeout_sec=timeout,
|
|
||||||
)
|
|
||||||
capsule = cls(
|
|
||||||
_capsule_id=info.id,
|
|
||||||
_client=client,
|
|
||||||
_info=info,
|
|
||||||
)
|
|
||||||
if wait:
|
|
||||||
await capsule.wait_ready()
|
|
||||||
return capsule
|
|
||||||
|
|
||||||
def _get_proxy_client(self) -> httpx.AsyncClient:
|
|
||||||
if self._proxy_client is None:
|
|
||||||
url = (
|
|
||||||
_build_proxy_url(self._client._base_url, self._id, 8888)
|
|
||||||
.replace("ws://", "http://")
|
|
||||||
.replace("wss://", "https://")
|
|
||||||
)
|
|
||||||
self._proxy_client = httpx.AsyncClient(
|
|
||||||
base_url=url,
|
|
||||||
headers={"X-API-Key": self._client._api_key},
|
|
||||||
)
|
|
||||||
return self._proxy_client
|
|
||||||
|
|
||||||
async def _ensure_kernel(self, jupyter_timeout: float = 30) -> str:
|
|
||||||
if self._kernel_id is not None:
|
|
||||||
return self._kernel_id
|
|
||||||
|
|
||||||
client = self._get_proxy_client()
|
|
||||||
deadline = time.monotonic() + jupyter_timeout
|
|
||||||
last_exc: Exception | None = None
|
|
||||||
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
try:
|
|
||||||
# Try to reuse an existing kernel
|
|
||||||
resp = await client.get("/api/kernels")
|
|
||||||
if resp.status_code < 500:
|
|
||||||
resp.raise_for_status()
|
|
||||||
kernels = resp.json()
|
|
||||||
if kernels:
|
|
||||||
self._kernel_id = kernels[0]["id"]
|
|
||||||
return self._kernel_id
|
|
||||||
# No existing kernels, create a new one
|
|
||||||
resp = await client.post("/api/kernels")
|
|
||||||
if resp.status_code < 500:
|
|
||||||
resp.raise_for_status()
|
|
||||||
self._kernel_id = resp.json()["id"]
|
|
||||||
return self._kernel_id
|
|
||||||
last_exc = httpx.HTTPStatusError(
|
|
||||||
f"Jupyter returned {resp.status_code}",
|
|
||||||
request=resp.request,
|
|
||||||
response=resp,
|
|
||||||
)
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
if exc.response.status_code < 500:
|
|
||||||
raise
|
|
||||||
last_exc = exc
|
|
||||||
except Exception as exc:
|
|
||||||
last_exc = exc
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
raise TimeoutError(
|
|
||||||
f"Jupyter not available within {jupyter_timeout}s: {last_exc}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _jupyter_ws_url(self, kernel_id: str) -> str:
|
|
||||||
proxy = _build_proxy_url(self._client._base_url, self._id, 8888)
|
|
||||||
return f"{proxy}/api/kernels/{kernel_id}/channels"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _jupyter_execute_request(code: str) -> dict:
|
|
||||||
msg_id = str(uuid.uuid4())
|
|
||||||
return {
|
|
||||||
"header": {
|
|
||||||
"msg_id": msg_id,
|
|
||||||
"msg_type": "execute_request",
|
|
||||||
"username": "wrenn-sdk",
|
|
||||||
"session": str(uuid.uuid4()),
|
|
||||||
"date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()),
|
|
||||||
"version": "5.3",
|
|
||||||
},
|
|
||||||
"parent_header": {},
|
|
||||||
"metadata": {},
|
|
||||||
"content": {
|
|
||||||
"code": code,
|
|
||||||
"silent": False,
|
|
||||||
"store_history": True,
|
|
||||||
"user_expressions": {},
|
|
||||||
"allow_stdin": False,
|
|
||||||
"stop_on_error": True,
|
|
||||||
},
|
|
||||||
"buffers": [],
|
|
||||||
"channel": "shell",
|
|
||||||
}
|
|
||||||
|
|
||||||
async def run_code(
|
|
||||||
self,
|
|
||||||
code: str,
|
|
||||||
language: str = "python",
|
|
||||||
timeout: float = 30,
|
|
||||||
jupyter_timeout: float = 30,
|
|
||||||
on_result: Callable[[Result], Any] | None = None,
|
|
||||||
on_stdout: Callable[[str], Any] | None = None,
|
|
||||||
on_stderr: Callable[[str], Any] | None = None,
|
|
||||||
on_error: Callable[[ExecutionError], Any] | None = None,
|
|
||||||
) -> Execution:
|
|
||||||
"""Execute code in a persistent Jupyter kernel (async).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
code: Code string to execute.
|
|
||||||
language: Execution backend language. Currently only ``"python"``.
|
|
||||||
timeout: Maximum seconds to wait for execution to complete.
|
|
||||||
jupyter_timeout: Maximum seconds to wait for Jupyter to become
|
|
||||||
available.
|
|
||||||
on_result: Called for each rich output (charts, images, expression
|
|
||||||
values).
|
|
||||||
on_stdout: Called for each stdout chunk.
|
|
||||||
on_stderr: Called for each stderr chunk.
|
|
||||||
on_error: Called when the cell raises an exception.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
An :class:`Execution` with ``.results``, ``.logs``, ``.error``,
|
|
||||||
and a convenience ``.text`` property.
|
|
||||||
"""
|
|
||||||
kernel_id = await self._ensure_kernel(jupyter_timeout=jupyter_timeout)
|
|
||||||
ws_url = self._jupyter_ws_url(kernel_id)
|
|
||||||
|
|
||||||
msg = self._jupyter_execute_request(code)
|
|
||||||
msg_id = msg["header"]["msg_id"]
|
|
||||||
|
|
||||||
execution = Execution()
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
headers = {"X-API-Key": self._client._api_key}
|
|
||||||
|
|
||||||
async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.AsyncWebSocketSession
|
|
||||||
await ws.send_text(json.dumps(msg))
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
time_left = deadline - time.monotonic()
|
|
||||||
if time_left <= 0:
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
data = await asyncio.wait_for(ws.receive_json(), timeout=time_left)
|
|
||||||
except Exception:
|
|
||||||
break
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
parent = data.get("parent_header", {}).get("msg_id")
|
|
||||||
if parent != msg_id:
|
|
||||||
continue
|
|
||||||
msg_type = data.get("msg_type") or data.get("header", {}).get(
|
|
||||||
"msg_type"
|
|
||||||
)
|
|
||||||
content = data.get("content", {})
|
|
||||||
|
|
||||||
if msg_type == "stream":
|
|
||||||
text = content.get("text", "")
|
|
||||||
name = content.get("name", "stdout")
|
|
||||||
if name == "stderr":
|
|
||||||
execution.logs.stderr.append(text)
|
|
||||||
if on_stderr is not None:
|
|
||||||
on_stderr(text)
|
|
||||||
else:
|
|
||||||
execution.logs.stdout.append(text)
|
|
||||||
if on_stdout is not None:
|
|
||||||
on_stdout(text)
|
|
||||||
elif msg_type in ("execute_result", "display_data"):
|
|
||||||
bundle = content.get("data", {})
|
|
||||||
is_main = msg_type == "execute_result"
|
|
||||||
result = Result.from_bundle(bundle, is_main_result=is_main)
|
|
||||||
execution.results.append(result)
|
|
||||||
if is_main:
|
|
||||||
execution.execution_count = content.get("execution_count")
|
|
||||||
if on_result is not None:
|
|
||||||
on_result(result)
|
|
||||||
elif msg_type == "error":
|
|
||||||
err = ExecutionError(
|
|
||||||
name=content.get("ename", ""),
|
|
||||||
value=content.get("evalue", ""),
|
|
||||||
traceback="\n".join(content.get("traceback", [])),
|
|
||||||
)
|
|
||||||
execution.error = err
|
|
||||||
if on_error is not None:
|
|
||||||
on_error(err)
|
|
||||||
elif msg_type == "status" and content.get("execution_state") == "idle":
|
|
||||||
break
|
|
||||||
|
|
||||||
return execution
|
|
||||||
|
|
||||||
async def __aexit__(self, *args) -> None:
|
|
||||||
if self._proxy_client is not None:
|
|
||||||
try:
|
|
||||||
await self._proxy_client.aclose()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
await super().__aexit__(*args)
|
|
||||||
|
|||||||
@ -1,307 +1,7 @@
|
|||||||
from __future__ import annotations
|
"""Deprecated — use :mod:`wrenn.code_runner.capsule`."""
|
||||||
|
|
||||||
import json
|
from wrenn.code_runner.capsule import ( # noqa: F401
|
||||||
import time
|
DEFAULT_KERNEL,
|
||||||
import uuid
|
DEFAULT_TEMPLATE,
|
||||||
from collections.abc import Callable
|
Capsule,
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import httpx_ws
|
|
||||||
|
|
||||||
from wrenn.capsule import Capsule as BaseCapsule
|
|
||||||
from wrenn.capsule import _build_proxy_url
|
|
||||||
from wrenn.code_interpreter.models import (
|
|
||||||
Execution,
|
|
||||||
ExecutionError,
|
|
||||||
Result,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
DEFAULT_TEMPLATE = "code-runner-beta"
|
|
||||||
|
|
||||||
|
|
||||||
class Capsule(BaseCapsule):
|
|
||||||
"""Code interpreter capsule with ``run_code`` support.
|
|
||||||
|
|
||||||
Uses ``code-runner-beta`` template by default::
|
|
||||||
|
|
||||||
from wrenn.code_interpreter import Capsule
|
|
||||||
|
|
||||||
capsule = Capsule()
|
|
||||||
result = capsule.run_code("print('hello')")
|
|
||||||
print(result.logs.stdout) # ["hello\\n"]
|
|
||||||
"""
|
|
||||||
|
|
||||||
_kernel_id: str | None
|
|
||||||
_proxy_client: httpx.Client | None
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
template: str | None = None,
|
|
||||||
vcpus: int | None = None,
|
|
||||||
memory_mb: int | None = None,
|
|
||||||
timeout: int | None = None,
|
|
||||||
*,
|
|
||||||
api_key: str | None = None,
|
|
||||||
base_url: str | None = None,
|
|
||||||
**kwargs,
|
|
||||||
) -> None:
|
|
||||||
"""Create a code interpreter capsule.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template (str | None): Template to boot from. Defaults to
|
|
||||||
``"code-runner-beta"``.
|
|
||||||
vcpus (int | None): Number of virtual CPUs.
|
|
||||||
memory_mb (int | None): Memory in MiB.
|
|
||||||
timeout (int | None): Inactivity TTL in seconds before auto-pause.
|
|
||||||
api_key (str | None): Wrenn API key. Falls back to
|
|
||||||
``WRENN_API_KEY`` env var.
|
|
||||||
base_url (str | None): API base URL override.
|
|
||||||
"""
|
|
||||||
super().__init__(
|
|
||||||
template=template or DEFAULT_TEMPLATE,
|
|
||||||
vcpus=vcpus,
|
|
||||||
memory_mb=memory_mb,
|
|
||||||
timeout=timeout,
|
|
||||||
api_key=api_key,
|
|
||||||
base_url=base_url,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
self._kernel_id = None
|
|
||||||
self._proxy_client = None
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
if self._proxy_client is not None:
|
|
||||||
try:
|
|
||||||
self._proxy_client.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._proxy_client = None
|
|
||||||
|
|
||||||
def __del__(self) -> None:
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create(
|
|
||||||
cls,
|
|
||||||
template: str | None = None,
|
|
||||||
vcpus: int | None = None,
|
|
||||||
memory_mb: int | None = None,
|
|
||||||
timeout: int | None = None,
|
|
||||||
*,
|
|
||||||
wait: bool = False,
|
|
||||||
api_key: str | None = None,
|
|
||||||
base_url: str | None = None,
|
|
||||||
) -> Capsule:
|
|
||||||
"""Create a new code interpreter capsule.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template (str | None): Template to boot from. Defaults to
|
|
||||||
``"code-runner-beta"``.
|
|
||||||
vcpus (int | None): Number of virtual CPUs.
|
|
||||||
memory_mb (int | None): Memory in MiB.
|
|
||||||
timeout (int | None): Inactivity TTL in seconds before auto-pause.
|
|
||||||
wait (bool): Block until the capsule reaches ``running`` status.
|
|
||||||
api_key (str | None): Wrenn API key. Falls back to
|
|
||||||
``WRENN_API_KEY`` env var.
|
|
||||||
base_url (str | None): API base URL override.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Capsule: A new code interpreter capsule instance.
|
|
||||||
"""
|
|
||||||
return cls(
|
|
||||||
template=template or DEFAULT_TEMPLATE,
|
|
||||||
vcpus=vcpus,
|
|
||||||
memory_mb=memory_mb,
|
|
||||||
timeout=timeout,
|
|
||||||
wait=wait,
|
|
||||||
api_key=api_key,
|
|
||||||
base_url=base_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_proxy_client(self) -> httpx.Client:
|
|
||||||
if self._proxy_client is None:
|
|
||||||
url = (
|
|
||||||
_build_proxy_url(self._client._base_url, self._id, 8888)
|
|
||||||
.replace("ws://", "http://")
|
|
||||||
.replace("wss://", "https://")
|
|
||||||
)
|
|
||||||
self._proxy_client = httpx.Client(
|
|
||||||
base_url=url,
|
|
||||||
headers={"X-API-Key": self._client._api_key},
|
|
||||||
)
|
|
||||||
return self._proxy_client
|
|
||||||
|
|
||||||
def _ensure_kernel(self, jupyter_timeout: float = 30) -> str:
|
|
||||||
if self._kernel_id is not None:
|
|
||||||
return self._kernel_id
|
|
||||||
|
|
||||||
client = self._get_proxy_client()
|
|
||||||
deadline = time.monotonic() + jupyter_timeout
|
|
||||||
last_exc: Exception | None = None
|
|
||||||
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
try:
|
|
||||||
# Try to reuse an existing kernel
|
|
||||||
resp = client.get("/api/kernels")
|
|
||||||
if resp.status_code < 500:
|
|
||||||
resp.raise_for_status()
|
|
||||||
kernels = resp.json()
|
|
||||||
if kernels:
|
|
||||||
self._kernel_id = kernels[0]["id"]
|
|
||||||
return self._kernel_id
|
|
||||||
# No existing kernels, create a new one
|
|
||||||
resp = client.post("/api/kernels")
|
|
||||||
if resp.status_code < 500:
|
|
||||||
resp.raise_for_status()
|
|
||||||
self._kernel_id = resp.json()["id"]
|
|
||||||
return self._kernel_id
|
|
||||||
last_exc = httpx.HTTPStatusError(
|
|
||||||
f"Jupyter returned {resp.status_code}",
|
|
||||||
request=resp.request,
|
|
||||||
response=resp,
|
|
||||||
)
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
if exc.response.status_code < 500:
|
|
||||||
raise
|
|
||||||
last_exc = exc
|
|
||||||
except Exception as exc:
|
|
||||||
last_exc = exc
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
raise TimeoutError(
|
|
||||||
f"Jupyter not available within {jupyter_timeout}s: {last_exc}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _jupyter_ws_url(self, kernel_id: str) -> str:
|
|
||||||
proxy = _build_proxy_url(self._client._base_url, self._id, 8888)
|
|
||||||
return f"{proxy}/api/kernels/{kernel_id}/channels"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _jupyter_execute_request(code: str) -> dict:
|
|
||||||
msg_id = str(uuid.uuid4())
|
|
||||||
return {
|
|
||||||
"header": {
|
|
||||||
"msg_id": msg_id,
|
|
||||||
"msg_type": "execute_request",
|
|
||||||
"username": "wrenn-sdk",
|
|
||||||
"session": str(uuid.uuid4()),
|
|
||||||
"date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()),
|
|
||||||
"version": "5.3",
|
|
||||||
},
|
|
||||||
"parent_header": {},
|
|
||||||
"metadata": {},
|
|
||||||
"content": {
|
|
||||||
"code": code,
|
|
||||||
"silent": False,
|
|
||||||
"store_history": True,
|
|
||||||
"user_expressions": {},
|
|
||||||
"allow_stdin": False,
|
|
||||||
"stop_on_error": True,
|
|
||||||
},
|
|
||||||
"buffers": [],
|
|
||||||
"channel": "shell",
|
|
||||||
}
|
|
||||||
|
|
||||||
def run_code(
|
|
||||||
self,
|
|
||||||
code: str,
|
|
||||||
language: str = "python",
|
|
||||||
timeout: float = 30,
|
|
||||||
jupyter_timeout: float = 30,
|
|
||||||
on_result: Callable[[Result], Any] | None = None,
|
|
||||||
on_stdout: Callable[[str], Any] | None = None,
|
|
||||||
on_stderr: Callable[[str], Any] | None = None,
|
|
||||||
on_error: Callable[[ExecutionError], Any] | None = None,
|
|
||||||
) -> Execution:
|
|
||||||
"""Execute code in a persistent Jupyter kernel.
|
|
||||||
|
|
||||||
Variables, imports, and function definitions survive across calls.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
code: Code string to execute.
|
|
||||||
language: Execution backend language. Currently only ``"python"``.
|
|
||||||
timeout: Maximum seconds to wait for execution to complete.
|
|
||||||
jupyter_timeout: Maximum seconds to wait for Jupyter to become
|
|
||||||
available.
|
|
||||||
on_result: Called for each rich output (charts, images, expression
|
|
||||||
values).
|
|
||||||
on_stdout: Called for each stdout chunk.
|
|
||||||
on_stderr: Called for each stderr chunk.
|
|
||||||
on_error: Called when the cell raises an exception.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
An :class:`Execution` with ``.results``, ``.logs``, ``.error``,
|
|
||||||
and a convenience ``.text`` property.
|
|
||||||
"""
|
|
||||||
kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout)
|
|
||||||
ws_url = self._jupyter_ws_url(kernel_id)
|
|
||||||
|
|
||||||
msg = self._jupyter_execute_request(code)
|
|
||||||
msg_id = msg["header"]["msg_id"]
|
|
||||||
|
|
||||||
execution = Execution()
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
headers = {"X-API-Key": self._client._api_key}
|
|
||||||
|
|
||||||
with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.WebSocketSession
|
|
||||||
ws.send_text(json.dumps(msg))
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
time_left = deadline - time.monotonic()
|
|
||||||
if time_left <= 0:
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
data = ws.receive_json(timeout=time_left)
|
|
||||||
except Exception:
|
|
||||||
break
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
parent = data.get("parent_header", {}).get("msg_id")
|
|
||||||
if parent != msg_id:
|
|
||||||
continue
|
|
||||||
msg_type = data.get("msg_type") or data.get("header", {}).get(
|
|
||||||
"msg_type"
|
|
||||||
)
|
|
||||||
content = data.get("content", {})
|
|
||||||
|
|
||||||
if msg_type == "stream":
|
|
||||||
text = content.get("text", "")
|
|
||||||
name = content.get("name", "stdout")
|
|
||||||
if name == "stderr":
|
|
||||||
execution.logs.stderr.append(text)
|
|
||||||
if on_stderr is not None:
|
|
||||||
on_stderr(text)
|
|
||||||
else:
|
|
||||||
execution.logs.stdout.append(text)
|
|
||||||
if on_stdout is not None:
|
|
||||||
on_stdout(text)
|
|
||||||
elif msg_type in ("execute_result", "display_data"):
|
|
||||||
bundle = content.get("data", {})
|
|
||||||
is_main = msg_type == "execute_result"
|
|
||||||
result = Result.from_bundle(bundle, is_main_result=is_main)
|
|
||||||
execution.results.append(result)
|
|
||||||
if is_main:
|
|
||||||
execution.execution_count = content.get("execution_count")
|
|
||||||
if on_result is not None:
|
|
||||||
on_result(result)
|
|
||||||
elif msg_type == "error":
|
|
||||||
err = ExecutionError(
|
|
||||||
name=content.get("ename", ""),
|
|
||||||
value=content.get("evalue", ""),
|
|
||||||
traceback="\n".join(content.get("traceback", [])),
|
|
||||||
)
|
|
||||||
execution.error = err
|
|
||||||
if on_error is not None:
|
|
||||||
on_error(err)
|
|
||||||
elif msg_type == "status" and content.get("execution_state") == "idle":
|
|
||||||
break
|
|
||||||
|
|
||||||
return execution
|
|
||||||
|
|
||||||
def __exit__(self, *args) -> None:
|
|
||||||
if self._proxy_client is not None:
|
|
||||||
try:
|
|
||||||
self._proxy_client.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
super().__exit__(*args)
|
|
||||||
|
|||||||
@ -1,156 +1,8 @@
|
|||||||
from __future__ import annotations
|
"""Deprecated — use :mod:`wrenn.code_runner.models`."""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from wrenn.code_runner.models import ( # noqa: F401
|
||||||
|
Execution,
|
||||||
_MIME_MAP: dict[str, str] = {
|
ExecutionError,
|
||||||
"text/plain": "text",
|
Logs,
|
||||||
"text/html": "html",
|
Result,
|
||||||
"text/markdown": "markdown",
|
)
|
||||||
"image/svg+xml": "svg",
|
|
||||||
"image/png": "png",
|
|
||||||
"image/jpeg": "jpeg",
|
|
||||||
"application/pdf": "pdf",
|
|
||||||
"text/latex": "latex",
|
|
||||||
"application/json": "json",
|
|
||||||
"application/javascript": "javascript",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ExecutionError:
|
|
||||||
"""Error raised during code execution.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
name: Exception class name (e.g. ``"NameError"``).
|
|
||||||
value: Exception message.
|
|
||||||
traceback: Full traceback string.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str = ""
|
|
||||||
value: str = ""
|
|
||||||
traceback: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Logs:
|
|
||||||
"""Captured stdout/stderr streams.
|
|
||||||
|
|
||||||
Each element in the list is one chunk of text as it arrived from
|
|
||||||
the kernel.
|
|
||||||
"""
|
|
||||||
|
|
||||||
stdout: list[str] = field(default_factory=list)
|
|
||||||
stderr: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Result:
|
|
||||||
"""A single rich output from code execution.
|
|
||||||
|
|
||||||
Jupyter cells can produce multiple outputs — one ``execute_result``
|
|
||||||
(the expression value) and zero or more ``display_data`` messages
|
|
||||||
(from ``plt.show()``, ``display()``, etc.). Each becomes a
|
|
||||||
``Result``.
|
|
||||||
|
|
||||||
Known MIME types are unpacked into named attributes; anything else
|
|
||||||
lands in :pyattr:`extra`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# --- MIME type fields ---
|
|
||||||
text: str | None = None
|
|
||||||
"""``text/plain`` representation."""
|
|
||||||
html: str | None = None
|
|
||||||
"""``text/html`` representation."""
|
|
||||||
markdown: str | None = None
|
|
||||||
"""``text/markdown`` representation."""
|
|
||||||
svg: str | None = None
|
|
||||||
"""``image/svg+xml`` representation."""
|
|
||||||
png: str | None = None
|
|
||||||
"""``image/png`` — base64-encoded."""
|
|
||||||
jpeg: str | None = None
|
|
||||||
"""``image/jpeg`` — base64-encoded."""
|
|
||||||
pdf: str | None = None
|
|
||||||
"""``application/pdf`` — base64-encoded."""
|
|
||||||
latex: str | None = None
|
|
||||||
"""``text/latex`` representation."""
|
|
||||||
json: dict | None = None
|
|
||||||
"""``application/json`` representation."""
|
|
||||||
javascript: str | None = None
|
|
||||||
"""``application/javascript`` representation."""
|
|
||||||
extra: dict[str, str] | None = None
|
|
||||||
"""MIME types not covered by the named fields above."""
|
|
||||||
|
|
||||||
is_main_result: bool = False
|
|
||||||
"""``True`` when this came from an ``execute_result`` message
|
|
||||||
(i.e. the value of the last expression in the cell). ``False``
|
|
||||||
for ``display_data`` outputs."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_bundle(
|
|
||||||
cls, bundle: dict[str, str], *, is_main_result: bool = False
|
|
||||||
) -> Result:
|
|
||||||
"""Build a ``Result`` from a Jupyter MIME bundle dict."""
|
|
||||||
kwargs: dict = {"is_main_result": is_main_result}
|
|
||||||
extra: dict[str, str] = {}
|
|
||||||
for mime, value in bundle.items():
|
|
||||||
attr = _MIME_MAP.get(mime)
|
|
||||||
if attr is not None:
|
|
||||||
kwargs[attr] = value
|
|
||||||
else:
|
|
||||||
extra[mime] = value
|
|
||||||
if extra:
|
|
||||||
kwargs["extra"] = extra
|
|
||||||
# Strip surrounding quotes from text/plain (Jupyter repr artefact)
|
|
||||||
text = kwargs.get("text")
|
|
||||||
if isinstance(text, str) and len(text) >= 2:
|
|
||||||
if (text[0] == text[-1]) and text[0] in ("'", '"'):
|
|
||||||
kwargs["text"] = text[1:-1]
|
|
||||||
return cls(**kwargs)
|
|
||||||
|
|
||||||
def formats(self) -> list[str]:
|
|
||||||
"""Return names of non-``None`` MIME-type fields."""
|
|
||||||
out: list[str] = []
|
|
||||||
for attr in (
|
|
||||||
"text",
|
|
||||||
"html",
|
|
||||||
"markdown",
|
|
||||||
"svg",
|
|
||||||
"png",
|
|
||||||
"jpeg",
|
|
||||||
"pdf",
|
|
||||||
"latex",
|
|
||||||
"json",
|
|
||||||
"javascript",
|
|
||||||
):
|
|
||||||
if getattr(self, attr) is not None:
|
|
||||||
out.append(attr)
|
|
||||||
if self.extra:
|
|
||||||
out.extend(self.extra)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Execution:
|
|
||||||
"""Complete result of a ``run_code`` call.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
results: All rich outputs produced by the cell — charts, tables,
|
|
||||||
images, expression values, etc.
|
|
||||||
logs: Captured stdout/stderr text.
|
|
||||||
error: Populated when the cell raised an exception.
|
|
||||||
execution_count: Jupyter execution counter (the ``[N]`` number).
|
|
||||||
"""
|
|
||||||
|
|
||||||
results: list[Result] = field(default_factory=list)
|
|
||||||
logs: Logs = field(default_factory=Logs)
|
|
||||||
error: ExecutionError | None = None
|
|
||||||
execution_count: int | None = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def text(self) -> str | None:
|
|
||||||
"""Convenience — ``text/plain`` of the main ``execute_result``,
|
|
||||||
or ``None`` if the cell had no expression value."""
|
|
||||||
for r in self.results:
|
|
||||||
if r.is_main_result:
|
|
||||||
return r.text
|
|
||||||
return None
|
|
||||||
|
|||||||
51
src/wrenn/code_runner/__init__.py
Normal file
51
src/wrenn/code_runner/__init__.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"""Code runner — execute code in persistent Jupyter kernels.
|
||||||
|
|
||||||
|
Uses the ``code-runner-beta`` template and the ``wrenn`` Jupyter
|
||||||
|
kernelspec by default.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
from wrenn.code_runner import Capsule
|
||||||
|
|
||||||
|
with Capsule(wait=True) as capsule:
|
||||||
|
result = capsule.run_code("print('hello')")
|
||||||
|
print(result.logs.stdout)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from wrenn.code_runner.async_capsule import AsyncCapsule
|
||||||
|
from wrenn.code_runner.capsule import DEFAULT_KERNEL, DEFAULT_TEMPLATE, Capsule
|
||||||
|
from wrenn.code_runner.models import (
|
||||||
|
Execution,
|
||||||
|
ExecutionError,
|
||||||
|
Logs,
|
||||||
|
Result,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AsyncCapsule",
|
||||||
|
"Capsule",
|
||||||
|
"DEFAULT_KERNEL",
|
||||||
|
"DEFAULT_TEMPLATE",
|
||||||
|
"Execution",
|
||||||
|
"ExecutionError",
|
||||||
|
"Logs",
|
||||||
|
"Result",
|
||||||
|
"Sandbox",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> type:
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
_module = sys.modules[__name__]
|
||||||
|
|
||||||
|
if name == "Sandbox":
|
||||||
|
warnings.warn(
|
||||||
|
"'Sandbox' is deprecated, use 'Capsule' instead",
|
||||||
|
FutureWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
setattr(_module, name, Capsule)
|
||||||
|
return Capsule
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
133
src/wrenn/code_runner/_protocol.py
Normal file
133
src/wrenn/code_runner/_protocol.py
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
"""Shared Jupyter protocol helpers used by both sync and async capsules.
|
||||||
|
|
||||||
|
Pure functions only — no I/O, no sync/async coupling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from wrenn.capsule import _build_proxy_url
|
||||||
|
from wrenn.code_runner.models import (
|
||||||
|
Execution,
|
||||||
|
ExecutionError,
|
||||||
|
Result,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_execute_request(code: str) -> dict:
|
||||||
|
"""Build a Jupyter ``execute_request`` message envelope.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: A fully-formed Jupyter shell-channel message ready to be
|
||||||
|
JSON-serialized over the kernel WebSocket. The caller is
|
||||||
|
expected to read ``msg["header"]["msg_id"]`` to correlate
|
||||||
|
responses.
|
||||||
|
"""
|
||||||
|
msg_id = str(uuid.uuid4())
|
||||||
|
return {
|
||||||
|
"header": {
|
||||||
|
"msg_id": msg_id,
|
||||||
|
"msg_type": "execute_request",
|
||||||
|
"username": "wrenn-sdk",
|
||||||
|
"session": str(uuid.uuid4()),
|
||||||
|
"date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()),
|
||||||
|
"version": "5.3",
|
||||||
|
},
|
||||||
|
"parent_header": {},
|
||||||
|
"metadata": {},
|
||||||
|
"content": {
|
||||||
|
"code": code,
|
||||||
|
"silent": False,
|
||||||
|
"store_history": True,
|
||||||
|
"user_expressions": {},
|
||||||
|
"allow_stdin": False,
|
||||||
|
"stop_on_error": True,
|
||||||
|
},
|
||||||
|
"buffers": [],
|
||||||
|
"channel": "shell",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pick_kernel_id(kernels: list[dict], kernel_name: str) -> str | None:
|
||||||
|
"""Return the ID of the first kernel matching ``kernel_name``, else ``None``."""
|
||||||
|
for k in kernels:
|
||||||
|
if k.get("name") == kernel_name:
|
||||||
|
return k.get("id")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_kernel_message(
|
||||||
|
data: dict,
|
||||||
|
msg_id: str,
|
||||||
|
execution: Execution,
|
||||||
|
emit_error: Callable[[ExecutionError], None],
|
||||||
|
on_result: Callable[[Result], Any] | None,
|
||||||
|
on_stdout: Callable[[str], Any] | None,
|
||||||
|
on_stderr: Callable[[str], Any] | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Apply one Jupyter IOPub message to ``execution``.
|
||||||
|
|
||||||
|
Returns ``True`` when the message marks idle (cell done); the caller
|
||||||
|
should stop reading further messages.
|
||||||
|
"""
|
||||||
|
parent = data.get("parent_header", {}).get("msg_id")
|
||||||
|
if parent != msg_id:
|
||||||
|
return False
|
||||||
|
msg_type = data.get("msg_type") or data.get("header", {}).get("msg_type")
|
||||||
|
content = data.get("content", {})
|
||||||
|
|
||||||
|
if msg_type == "stream":
|
||||||
|
text = content.get("text", "")
|
||||||
|
name = content.get("name", "stdout")
|
||||||
|
if name == "stderr":
|
||||||
|
execution.logs.stderr.append(text)
|
||||||
|
if on_stderr is not None:
|
||||||
|
on_stderr(text)
|
||||||
|
else:
|
||||||
|
execution.logs.stdout.append(text)
|
||||||
|
if on_stdout is not None:
|
||||||
|
on_stdout(text)
|
||||||
|
elif msg_type in ("execute_result", "display_data"):
|
||||||
|
bundle = content.get("data", {})
|
||||||
|
is_main = msg_type == "execute_result"
|
||||||
|
result = Result.from_bundle(bundle, is_main_result=is_main)
|
||||||
|
execution.results.append(result)
|
||||||
|
if is_main:
|
||||||
|
execution.execution_count = content.get("execution_count")
|
||||||
|
if on_result is not None:
|
||||||
|
on_result(result)
|
||||||
|
elif msg_type == "error":
|
||||||
|
emit_error(
|
||||||
|
ExecutionError(
|
||||||
|
name=content.get("ename", ""),
|
||||||
|
value=content.get("evalue", ""),
|
||||||
|
traceback="\n".join(content.get("traceback", [])),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif msg_type == "status" and content.get("execution_state") == "idle":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_language(language: str) -> None:
|
||||||
|
if language != "python":
|
||||||
|
raise ValueError(
|
||||||
|
f"language={language!r} is not supported; only 'python'. "
|
||||||
|
"Use the ``kernel=`` constructor argument to target a "
|
||||||
|
"non-Python kernelspec."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_ws_url(
|
||||||
|
base_url: str,
|
||||||
|
capsule_id: str,
|
||||||
|
kernel_id: str,
|
||||||
|
proxy_domain: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build the Jupyter kernel WebSocket URL for the given capsule."""
|
||||||
|
proxy = _build_proxy_url(base_url, capsule_id, 8888, proxy_domain)
|
||||||
|
return f"{proxy}/api/kernels/{kernel_id}/channels"
|
||||||
334
src/wrenn/code_runner/async_capsule.py
Normal file
334
src/wrenn/code_runner/async_capsule.py
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import httpx_ws
|
||||||
|
|
||||||
|
from wrenn.async_capsule import AsyncCapsule as BaseAsyncCapsule
|
||||||
|
from wrenn.capsule import _build_http_proxy_url
|
||||||
|
from wrenn.client import AsyncWrennClient
|
||||||
|
from wrenn.code_runner._protocol import (
|
||||||
|
apply_kernel_message,
|
||||||
|
build_execute_request,
|
||||||
|
build_ws_url,
|
||||||
|
pick_kernel_id,
|
||||||
|
validate_language,
|
||||||
|
)
|
||||||
|
from wrenn.code_runner.capsule import DEFAULT_KERNEL, DEFAULT_TEMPLATE
|
||||||
|
from wrenn.code_runner.models import (
|
||||||
|
Execution,
|
||||||
|
ExecutionError,
|
||||||
|
Result,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncCapsule(BaseAsyncCapsule):
|
||||||
|
"""Async code runner capsule with ``run_code`` support.
|
||||||
|
|
||||||
|
Uses ``code-runner-beta`` template and the ``wrenn`` Jupyter
|
||||||
|
kernelspec by default::
|
||||||
|
|
||||||
|
from wrenn.code_runner import AsyncCapsule
|
||||||
|
|
||||||
|
capsule = await AsyncCapsule.create()
|
||||||
|
result = await capsule.run_code("print('hello')")
|
||||||
|
"""
|
||||||
|
|
||||||
|
_kernel_id: str | None
|
||||||
|
_kernel_name: str
|
||||||
|
_proxy_client: httpx.AsyncClient | None
|
||||||
|
_ws: httpx_ws.AsyncWebSocketSession | None
|
||||||
|
_ws_cm: Any
|
||||||
|
|
||||||
|
def __init__(self, *, kernel: str | None = None, **kwargs) -> None:
|
||||||
|
# Set attrs before super().__init__ so __del__ never sees a
|
||||||
|
# half-constructed instance.
|
||||||
|
self._kernel_id = None
|
||||||
|
self._kernel_name = kernel or DEFAULT_KERNEL
|
||||||
|
self._proxy_client = None
|
||||||
|
self._ws = None
|
||||||
|
self._ws_cm = None
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
async def _close_ws(self) -> None:
|
||||||
|
cm = getattr(self, "_ws_cm", None)
|
||||||
|
if cm is not None:
|
||||||
|
try:
|
||||||
|
await cm.__aexit__(None, None, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._ws = None
|
||||||
|
self._ws_cm = None
|
||||||
|
|
||||||
|
async def _get_ws(self, kernel_id: str) -> httpx_ws.AsyncWebSocketSession:
|
||||||
|
if self._ws is not None:
|
||||||
|
return self._ws
|
||||||
|
ws_url = build_ws_url(
|
||||||
|
self._client._base_url,
|
||||||
|
self._id,
|
||||||
|
kernel_id,
|
||||||
|
self._client._proxy_domain,
|
||||||
|
)
|
||||||
|
headers = {"X-API-Key": self._client._api_key}
|
||||||
|
cm: Any = httpx_ws.aconnect_ws(ws_url, headers=headers)
|
||||||
|
try:
|
||||||
|
ws = await cm.__aenter__()
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
await cm.__aexit__(None, None, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
self._ws_cm = cm
|
||||||
|
self._ws = ws
|
||||||
|
return ws
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self._close_ws()
|
||||||
|
proxy = getattr(self, "_proxy_client", None)
|
||||||
|
if proxy is not None:
|
||||||
|
try:
|
||||||
|
await proxy.aclose()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._proxy_client = None
|
||||||
|
|
||||||
|
def __del__(self) -> None:
|
||||||
|
# Async client cannot be safely closed from __del__; just drop the
|
||||||
|
# reference and let httpx warn if the connection was never closed.
|
||||||
|
# Users should call ``await close()`` or use ``async with``.
|
||||||
|
self._proxy_client = None
|
||||||
|
self._ws = None
|
||||||
|
self._ws_cm = None
|
||||||
|
|
||||||
|
async def _instance_destroy(self, wait: bool = False) -> None:
|
||||||
|
# Release WS + proxy client before destroying the capsule.
|
||||||
|
await self.close()
|
||||||
|
await super()._instance_destroy(wait=wait)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create(
|
||||||
|
cls,
|
||||||
|
template: str | None = None,
|
||||||
|
vcpus: int | None = None,
|
||||||
|
memory_mb: int | None = None,
|
||||||
|
timeout: int | None = None,
|
||||||
|
*,
|
||||||
|
kernel: str | None = None,
|
||||||
|
wait: bool = False,
|
||||||
|
api_key: str | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
|
) -> AsyncCapsule:
|
||||||
|
"""Create a new async code runner capsule.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template (str | None): Template to boot from. Defaults to
|
||||||
|
``"code-runner-beta"``.
|
||||||
|
vcpus (int | None): Number of virtual CPUs.
|
||||||
|
memory_mb (int | None): Memory in MiB.
|
||||||
|
timeout (int | None): Inactivity TTL in seconds before auto-pause.
|
||||||
|
kernel (str | None): Jupyter kernelspec name. Defaults to
|
||||||
|
``"wrenn"``.
|
||||||
|
wait (bool): Await until the capsule reaches ``running`` status.
|
||||||
|
api_key (str | None): Wrenn API key. Falls back to
|
||||||
|
``WRENN_API_KEY`` env var.
|
||||||
|
base_url (str | None): API base URL override.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AsyncCapsule: A new async code runner capsule instance.
|
||||||
|
"""
|
||||||
|
client = AsyncWrennClient(api_key=api_key, base_url=base_url)
|
||||||
|
try:
|
||||||
|
info = await client.capsules.create(
|
||||||
|
template=template or DEFAULT_TEMPLATE,
|
||||||
|
vcpus=vcpus,
|
||||||
|
memory_mb=memory_mb,
|
||||||
|
timeout_sec=timeout,
|
||||||
|
)
|
||||||
|
if info.id is None:
|
||||||
|
raise RuntimeError("API returned a capsule without an ID")
|
||||||
|
capsule = cls(
|
||||||
|
kernel=kernel,
|
||||||
|
_capsule_id=info.id,
|
||||||
|
_client=client,
|
||||||
|
_info=info,
|
||||||
|
)
|
||||||
|
if wait:
|
||||||
|
await capsule.wait_ready()
|
||||||
|
return capsule
|
||||||
|
except BaseException:
|
||||||
|
await client.aclose()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _get_proxy_client(self) -> httpx.AsyncClient:
|
||||||
|
if self._proxy_client is None:
|
||||||
|
url = _build_http_proxy_url(
|
||||||
|
self._client._base_url,
|
||||||
|
self._id,
|
||||||
|
8888,
|
||||||
|
self._client._proxy_domain,
|
||||||
|
)
|
||||||
|
self._proxy_client = httpx.AsyncClient(
|
||||||
|
base_url=url,
|
||||||
|
headers={"X-API-Key": self._client._api_key},
|
||||||
|
)
|
||||||
|
return self._proxy_client
|
||||||
|
|
||||||
|
async def _ensure_kernel(self, jupyter_timeout: float = 30) -> str:
|
||||||
|
if self._kernel_id is not None:
|
||||||
|
return self._kernel_id
|
||||||
|
|
||||||
|
client = self._get_proxy_client()
|
||||||
|
deadline = time.monotonic() + jupyter_timeout
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
resp = await client.get("/api/kernels")
|
||||||
|
if resp.status_code < 500:
|
||||||
|
resp.raise_for_status()
|
||||||
|
matched = pick_kernel_id(resp.json(), self._kernel_name)
|
||||||
|
if matched is not None:
|
||||||
|
self._kernel_id = matched
|
||||||
|
return matched
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/kernels",
|
||||||
|
json={"name": self._kernel_name},
|
||||||
|
)
|
||||||
|
if resp.status_code < 500:
|
||||||
|
resp.raise_for_status()
|
||||||
|
self._kernel_id = resp.json()["id"]
|
||||||
|
return self._kernel_id
|
||||||
|
last_exc = httpx.HTTPStatusError(
|
||||||
|
f"Jupyter returned {resp.status_code}",
|
||||||
|
request=resp.request,
|
||||||
|
response=resp,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if exc.response.status_code < 500:
|
||||||
|
raise
|
||||||
|
last_exc = exc
|
||||||
|
except Exception as exc:
|
||||||
|
last_exc = exc
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Jupyter not available within {jupyter_timeout}s: {last_exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_code(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
language: str = "python",
|
||||||
|
timeout: float = 30,
|
||||||
|
jupyter_timeout: float = 30,
|
||||||
|
on_result: Callable[[Result], Any] | None = None,
|
||||||
|
on_stdout: Callable[[str], Any] | None = None,
|
||||||
|
on_stderr: Callable[[str], Any] | None = None,
|
||||||
|
on_error: Callable[[ExecutionError], Any] | None = None,
|
||||||
|
) -> Execution:
|
||||||
|
"""Execute code in a persistent Jupyter kernel (async).
|
||||||
|
|
||||||
|
Variables, imports, and function definitions survive across calls.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: Code string to execute.
|
||||||
|
language: Execution backend language. Currently only ``"python"``
|
||||||
|
is supported; passing anything else raises ``ValueError``.
|
||||||
|
To target a non-Python kernel, set ``kernel=`` on the
|
||||||
|
capsule constructor.
|
||||||
|
timeout: Maximum seconds to wait for execution to complete.
|
||||||
|
jupyter_timeout: Maximum seconds to wait for Jupyter to become
|
||||||
|
available.
|
||||||
|
on_result: Called for each rich output (charts, images, expression
|
||||||
|
values).
|
||||||
|
on_stdout: Called for each stdout chunk.
|
||||||
|
on_stderr: Called for each stderr chunk.
|
||||||
|
on_error: Called when the cell raises an exception.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An :class:`Execution` with ``.results``, ``.logs``, ``.error``,
|
||||||
|
and a convenience ``.text`` property.
|
||||||
|
"""
|
||||||
|
validate_language(language)
|
||||||
|
kernel_id = await self._ensure_kernel(jupyter_timeout=jupyter_timeout)
|
||||||
|
|
||||||
|
msg = build_execute_request(code)
|
||||||
|
msg_id = msg["header"]["msg_id"]
|
||||||
|
|
||||||
|
execution = Execution()
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
saw_idle = False
|
||||||
|
|
||||||
|
def _emit_error(err: ExecutionError) -> None:
|
||||||
|
execution.error = err
|
||||||
|
if on_error is not None:
|
||||||
|
on_error(err)
|
||||||
|
|
||||||
|
reconnect_attempts = 1
|
||||||
|
sent = False
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
ws = await self._get_ws(kernel_id)
|
||||||
|
if not sent:
|
||||||
|
await ws.send_text(json.dumps(msg))
|
||||||
|
sent = True
|
||||||
|
while True:
|
||||||
|
time_left = deadline - time.monotonic()
|
||||||
|
if time_left <= 0:
|
||||||
|
break
|
||||||
|
data = await asyncio.wait_for(ws.receive_json(), timeout=time_left)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
if apply_kernel_message(
|
||||||
|
data,
|
||||||
|
msg_id,
|
||||||
|
execution,
|
||||||
|
_emit_error,
|
||||||
|
on_result,
|
||||||
|
on_stdout,
|
||||||
|
on_stderr,
|
||||||
|
):
|
||||||
|
saw_idle = True
|
||||||
|
break
|
||||||
|
break
|
||||||
|
except TimeoutError:
|
||||||
|
break
|
||||||
|
except (
|
||||||
|
httpx_ws.WebSocketDisconnect,
|
||||||
|
httpx_ws.WebSocketNetworkError,
|
||||||
|
httpx.ReadError,
|
||||||
|
httpx.RemoteProtocolError,
|
||||||
|
) as exc:
|
||||||
|
await self._close_ws()
|
||||||
|
if reconnect_attempts > 0 and not sent:
|
||||||
|
reconnect_attempts -= 1
|
||||||
|
continue
|
||||||
|
_emit_error(
|
||||||
|
ExecutionError(
|
||||||
|
name="Disconnected",
|
||||||
|
value=f"kernel WebSocket closed: {exc}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
execution.timed_out = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not saw_idle and execution.error is None:
|
||||||
|
execution.timed_out = True
|
||||||
|
_emit_error(
|
||||||
|
ExecutionError(
|
||||||
|
name="Timeout",
|
||||||
|
value=f"run_code exceeded {timeout}s",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return execution
|
||||||
|
|
||||||
|
async def __aexit__(self, *args) -> None:
|
||||||
|
await self.close()
|
||||||
|
await super().__aexit__(*args)
|
||||||
358
src/wrenn/code_runner/capsule.py
Normal file
358
src/wrenn/code_runner/capsule.py
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import httpx_ws
|
||||||
|
|
||||||
|
from wrenn.capsule import Capsule as BaseCapsule
|
||||||
|
from wrenn.capsule import _build_http_proxy_url
|
||||||
|
from wrenn.code_runner._protocol import (
|
||||||
|
apply_kernel_message,
|
||||||
|
build_execute_request,
|
||||||
|
build_ws_url,
|
||||||
|
pick_kernel_id,
|
||||||
|
validate_language,
|
||||||
|
)
|
||||||
|
from wrenn.code_runner.models import (
|
||||||
|
Execution,
|
||||||
|
ExecutionError,
|
||||||
|
Result,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_TEMPLATE = "code-runner-beta"
|
||||||
|
DEFAULT_KERNEL = "wrenn"
|
||||||
|
|
||||||
|
|
||||||
|
class Capsule(BaseCapsule):
|
||||||
|
"""Code runner capsule with ``run_code`` support.
|
||||||
|
|
||||||
|
Uses ``code-runner-beta`` template and the ``wrenn`` Jupyter
|
||||||
|
kernelspec by default::
|
||||||
|
|
||||||
|
from wrenn.code_runner import Capsule
|
||||||
|
|
||||||
|
capsule = Capsule()
|
||||||
|
result = capsule.run_code("print('hello')")
|
||||||
|
print(result.logs.stdout) # ["hello\\n"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
_kernel_id: str | None
|
||||||
|
_kernel_name: str
|
||||||
|
_proxy_client: httpx.Client | None
|
||||||
|
_ws: httpx_ws.WebSocketSession | None
|
||||||
|
_ws_cm: Any
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
template: str | None = None,
|
||||||
|
vcpus: int | None = None,
|
||||||
|
memory_mb: int | None = None,
|
||||||
|
timeout: int | None = None,
|
||||||
|
*,
|
||||||
|
kernel: str | None = None,
|
||||||
|
api_key: str | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
|
"""Create a code runner capsule.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template (str | None): Template to boot from. Defaults to
|
||||||
|
``"code-runner-beta"``.
|
||||||
|
vcpus (int | None): Number of virtual CPUs.
|
||||||
|
memory_mb (int | None): Memory in MiB.
|
||||||
|
timeout (int | None): Inactivity TTL in seconds before auto-pause.
|
||||||
|
kernel (str | None): Jupyter kernelspec name. Defaults to
|
||||||
|
``"wrenn"``.
|
||||||
|
api_key (str | None): Wrenn API key. Falls back to
|
||||||
|
``WRENN_API_KEY`` env var.
|
||||||
|
base_url (str | None): API base URL override.
|
||||||
|
"""
|
||||||
|
# Set attrs before super().__init__ so __del__ never sees a
|
||||||
|
# half-constructed instance if creation fails.
|
||||||
|
self._kernel_id = None
|
||||||
|
self._kernel_name = kernel or DEFAULT_KERNEL
|
||||||
|
self._proxy_client = None
|
||||||
|
self._ws = None
|
||||||
|
self._ws_cm = None
|
||||||
|
super().__init__(
|
||||||
|
template=template or DEFAULT_TEMPLATE,
|
||||||
|
vcpus=vcpus,
|
||||||
|
memory_mb=memory_mb,
|
||||||
|
timeout=timeout,
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=base_url,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _close_ws(self) -> None:
|
||||||
|
cm = getattr(self, "_ws_cm", None)
|
||||||
|
if cm is not None:
|
||||||
|
try:
|
||||||
|
cm.__exit__(None, None, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._ws = None
|
||||||
|
self._ws_cm = None
|
||||||
|
|
||||||
|
def _get_ws(self, kernel_id: str) -> httpx_ws.WebSocketSession:
|
||||||
|
if self._ws is not None:
|
||||||
|
return self._ws
|
||||||
|
ws_url = build_ws_url(
|
||||||
|
self._client._base_url,
|
||||||
|
self._id,
|
||||||
|
kernel_id,
|
||||||
|
self._client._proxy_domain,
|
||||||
|
)
|
||||||
|
headers = {"X-API-Key": self._client._api_key}
|
||||||
|
cm: Any = httpx_ws.connect_ws(ws_url, headers=headers)
|
||||||
|
try:
|
||||||
|
ws = cm.__enter__()
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
cm.__exit__(None, None, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
self._ws_cm = cm
|
||||||
|
self._ws = ws
|
||||||
|
return ws
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._close_ws()
|
||||||
|
proxy = getattr(self, "_proxy_client", None)
|
||||||
|
if proxy is not None:
|
||||||
|
try:
|
||||||
|
proxy.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._proxy_client = None
|
||||||
|
|
||||||
|
def __del__(self) -> None:
|
||||||
|
try:
|
||||||
|
self.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _instance_destroy(self, wait: bool = False) -> None:
|
||||||
|
# Release WS threads + proxy client before destroying.
|
||||||
|
# httpx_ws sync sessions spawn non-daemon threads; not joining
|
||||||
|
# them keeps the interpreter alive after tests/scripts return.
|
||||||
|
self.close()
|
||||||
|
super()._instance_destroy(wait=wait)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
template: str | None = None,
|
||||||
|
vcpus: int | None = None,
|
||||||
|
memory_mb: int | None = None,
|
||||||
|
timeout: int | None = None,
|
||||||
|
*,
|
||||||
|
kernel: str | None = None,
|
||||||
|
wait: bool = False,
|
||||||
|
api_key: str | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
|
) -> Capsule:
|
||||||
|
"""Create a new code runner capsule.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template (str | None): Template to boot from. Defaults to
|
||||||
|
``"code-runner-beta"``.
|
||||||
|
vcpus (int | None): Number of virtual CPUs.
|
||||||
|
memory_mb (int | None): Memory in MiB.
|
||||||
|
timeout (int | None): Inactivity TTL in seconds before auto-pause.
|
||||||
|
kernel (str | None): Jupyter kernelspec name. Defaults to
|
||||||
|
``"wrenn"``.
|
||||||
|
wait (bool): Block until the capsule reaches ``running`` status.
|
||||||
|
api_key (str | None): Wrenn API key. Falls back to
|
||||||
|
``WRENN_API_KEY`` env var.
|
||||||
|
base_url (str | None): API base URL override.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Capsule: A new code runner capsule instance.
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
template=template or DEFAULT_TEMPLATE,
|
||||||
|
vcpus=vcpus,
|
||||||
|
memory_mb=memory_mb,
|
||||||
|
timeout=timeout,
|
||||||
|
kernel=kernel,
|
||||||
|
wait=wait,
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_proxy_client(self) -> httpx.Client:
|
||||||
|
if self._proxy_client is None:
|
||||||
|
url = _build_http_proxy_url(
|
||||||
|
self._client._base_url,
|
||||||
|
self._id,
|
||||||
|
8888,
|
||||||
|
self._client._proxy_domain,
|
||||||
|
)
|
||||||
|
self._proxy_client = httpx.Client(
|
||||||
|
base_url=url,
|
||||||
|
headers={"X-API-Key": self._client._api_key},
|
||||||
|
)
|
||||||
|
return self._proxy_client
|
||||||
|
|
||||||
|
def _ensure_kernel(self, jupyter_timeout: float = 30) -> str:
|
||||||
|
if self._kernel_id is not None:
|
||||||
|
return self._kernel_id
|
||||||
|
|
||||||
|
client = self._get_proxy_client()
|
||||||
|
deadline = time.monotonic() + jupyter_timeout
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
# Try to reuse an existing kernel of the requested kernelspec.
|
||||||
|
resp = client.get("/api/kernels")
|
||||||
|
if resp.status_code < 500:
|
||||||
|
resp.raise_for_status()
|
||||||
|
matched = pick_kernel_id(resp.json(), self._kernel_name)
|
||||||
|
if matched is not None:
|
||||||
|
self._kernel_id = matched
|
||||||
|
return matched
|
||||||
|
# No matching kernel; create one with the requested spec.
|
||||||
|
resp = client.post(
|
||||||
|
"/api/kernels",
|
||||||
|
json={"name": self._kernel_name},
|
||||||
|
)
|
||||||
|
if resp.status_code < 500:
|
||||||
|
resp.raise_for_status()
|
||||||
|
self._kernel_id = resp.json()["id"]
|
||||||
|
return self._kernel_id
|
||||||
|
last_exc = httpx.HTTPStatusError(
|
||||||
|
f"Jupyter returned {resp.status_code}",
|
||||||
|
request=resp.request,
|
||||||
|
response=resp,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if exc.response.status_code < 500:
|
||||||
|
raise
|
||||||
|
last_exc = exc
|
||||||
|
except Exception as exc:
|
||||||
|
last_exc = exc
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Jupyter not available within {jupyter_timeout}s: {last_exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_code(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
language: str = "python",
|
||||||
|
timeout: float = 30,
|
||||||
|
jupyter_timeout: float = 30,
|
||||||
|
on_result: Callable[[Result], Any] | None = None,
|
||||||
|
on_stdout: Callable[[str], Any] | None = None,
|
||||||
|
on_stderr: Callable[[str], Any] | None = None,
|
||||||
|
on_error: Callable[[ExecutionError], Any] | None = None,
|
||||||
|
) -> Execution:
|
||||||
|
"""Execute code in a persistent Jupyter kernel.
|
||||||
|
|
||||||
|
Variables, imports, and function definitions survive across calls.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: Code string to execute.
|
||||||
|
language: Execution backend language. Currently only ``"python"``
|
||||||
|
is supported; passing anything else raises ``ValueError``.
|
||||||
|
To target a non-Python kernel, set ``kernel=`` on the
|
||||||
|
capsule constructor.
|
||||||
|
timeout: Maximum seconds to wait for execution to complete.
|
||||||
|
jupyter_timeout: Maximum seconds to wait for Jupyter to become
|
||||||
|
available.
|
||||||
|
on_result: Called for each rich output (charts, images, expression
|
||||||
|
values).
|
||||||
|
on_stdout: Called for each stdout chunk.
|
||||||
|
on_stderr: Called for each stderr chunk.
|
||||||
|
on_error: Called when the cell raises an exception.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An :class:`Execution` with ``.results``, ``.logs``, ``.error``,
|
||||||
|
and a convenience ``.text`` property.
|
||||||
|
"""
|
||||||
|
validate_language(language)
|
||||||
|
kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout)
|
||||||
|
|
||||||
|
msg = build_execute_request(code)
|
||||||
|
msg_id = msg["header"]["msg_id"]
|
||||||
|
|
||||||
|
execution = Execution()
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
saw_idle = False
|
||||||
|
|
||||||
|
def _emit_error(err: ExecutionError) -> None:
|
||||||
|
execution.error = err
|
||||||
|
if on_error is not None:
|
||||||
|
on_error(err)
|
||||||
|
|
||||||
|
reconnect_attempts = 1
|
||||||
|
sent = False
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
ws = self._get_ws(kernel_id)
|
||||||
|
if not sent:
|
||||||
|
ws.send_text(json.dumps(msg))
|
||||||
|
sent = True
|
||||||
|
while True:
|
||||||
|
time_left = deadline - time.monotonic()
|
||||||
|
if time_left <= 0:
|
||||||
|
break
|
||||||
|
data = ws.receive_json(timeout=time_left)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
if apply_kernel_message(
|
||||||
|
data,
|
||||||
|
msg_id,
|
||||||
|
execution,
|
||||||
|
_emit_error,
|
||||||
|
on_result,
|
||||||
|
on_stdout,
|
||||||
|
on_stderr,
|
||||||
|
):
|
||||||
|
saw_idle = True
|
||||||
|
break
|
||||||
|
break
|
||||||
|
except TimeoutError:
|
||||||
|
break
|
||||||
|
except (
|
||||||
|
httpx_ws.WebSocketDisconnect,
|
||||||
|
httpx_ws.WebSocketNetworkError,
|
||||||
|
httpx.ReadError,
|
||||||
|
httpx.RemoteProtocolError,
|
||||||
|
) as exc:
|
||||||
|
self._close_ws()
|
||||||
|
if reconnect_attempts > 0 and not sent:
|
||||||
|
reconnect_attempts -= 1
|
||||||
|
continue
|
||||||
|
_emit_error(
|
||||||
|
ExecutionError(
|
||||||
|
name="Disconnected",
|
||||||
|
value=f"kernel WebSocket closed: {exc}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
execution.timed_out = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not saw_idle and execution.error is None:
|
||||||
|
execution.timed_out = True
|
||||||
|
_emit_error(
|
||||||
|
ExecutionError(
|
||||||
|
name="Timeout",
|
||||||
|
value=f"run_code exceeded {timeout}s",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return execution
|
||||||
|
|
||||||
|
def __exit__(self, *args) -> None:
|
||||||
|
self.close()
|
||||||
|
super().__exit__(*args)
|
||||||
149
src/wrenn/code_runner/models.py
Normal file
149
src/wrenn/code_runner/models.py
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
_MIME_MAP: dict[str, str] = {
|
||||||
|
"text/plain": "text",
|
||||||
|
"text/html": "html",
|
||||||
|
"text/markdown": "markdown",
|
||||||
|
"image/svg+xml": "svg",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/jpeg": "jpeg",
|
||||||
|
"image/gif": "gif",
|
||||||
|
"application/pdf": "pdf",
|
||||||
|
"text/latex": "latex",
|
||||||
|
"application/json": "json",
|
||||||
|
"application/javascript": "javascript",
|
||||||
|
"application/vnd.plotly.v1+json": "plotly",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExecutionError:
|
||||||
|
"""Error raised during code execution.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Exception class name (e.g. ``"NameError"``).
|
||||||
|
value: Exception message.
|
||||||
|
traceback: Full traceback string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
|
value: str = ""
|
||||||
|
traceback: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Logs:
|
||||||
|
"""Captured stdout/stderr streams.
|
||||||
|
|
||||||
|
Each element in the list is one chunk of text as it arrived from
|
||||||
|
the kernel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
stdout: list[str] = field(default_factory=list)
|
||||||
|
stderr: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Result:
|
||||||
|
"""A single rich output from code execution.
|
||||||
|
|
||||||
|
Jupyter cells can produce multiple outputs — one ``execute_result``
|
||||||
|
(the expression value) and zero or more ``display_data`` messages
|
||||||
|
(from ``plt.show()``, ``display()``, etc.). Each becomes a
|
||||||
|
``Result``.
|
||||||
|
|
||||||
|
Known MIME types are unpacked into named attributes; anything else
|
||||||
|
lands in :pyattr:`extra`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# --- MIME type fields ---
|
||||||
|
text: str | None = None
|
||||||
|
"""``text/plain`` representation."""
|
||||||
|
html: str | None = None
|
||||||
|
"""``text/html`` representation."""
|
||||||
|
markdown: str | None = None
|
||||||
|
"""``text/markdown`` representation."""
|
||||||
|
svg: str | None = None
|
||||||
|
"""``image/svg+xml`` representation."""
|
||||||
|
png: str | None = None
|
||||||
|
"""``image/png`` — base64-encoded."""
|
||||||
|
jpeg: str | None = None
|
||||||
|
"""``image/jpeg`` — base64-encoded."""
|
||||||
|
gif: str | None = None
|
||||||
|
"""``image/gif`` — base64-encoded."""
|
||||||
|
pdf: str | None = None
|
||||||
|
"""``application/pdf`` — base64-encoded."""
|
||||||
|
latex: str | None = None
|
||||||
|
"""``text/latex`` representation."""
|
||||||
|
json: dict | None = None
|
||||||
|
"""``application/json`` representation."""
|
||||||
|
javascript: str | None = None
|
||||||
|
"""``application/javascript`` representation."""
|
||||||
|
plotly: dict | None = None
|
||||||
|
"""``application/vnd.plotly.v1+json`` representation."""
|
||||||
|
extra: dict[str, str] | None = None
|
||||||
|
"""MIME types not covered by the named fields above."""
|
||||||
|
|
||||||
|
is_main_result: bool = False
|
||||||
|
"""``True`` when this came from an ``execute_result`` message
|
||||||
|
(i.e. the value of the last expression in the cell). ``False``
|
||||||
|
for ``display_data`` outputs."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bundle(
|
||||||
|
cls, bundle: dict[str, str], *, is_main_result: bool = False
|
||||||
|
) -> Result:
|
||||||
|
"""Build a ``Result`` from a Jupyter MIME bundle dict."""
|
||||||
|
kwargs: dict = {"is_main_result": is_main_result}
|
||||||
|
extra: dict[str, str] = {}
|
||||||
|
for mime, value in bundle.items():
|
||||||
|
attr = _MIME_MAP.get(mime)
|
||||||
|
if attr is not None:
|
||||||
|
kwargs[attr] = value
|
||||||
|
else:
|
||||||
|
extra[mime] = value
|
||||||
|
if extra:
|
||||||
|
kwargs["extra"] = extra
|
||||||
|
return cls(**kwargs)
|
||||||
|
|
||||||
|
def formats(self) -> list[str]:
|
||||||
|
"""Return names of non-``None`` MIME-type fields."""
|
||||||
|
out: list[str] = [
|
||||||
|
attr for attr in _MIME_MAP.values() if getattr(self, attr) is not None
|
||||||
|
]
|
||||||
|
if self.extra:
|
||||||
|
out.extend(self.extra)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Execution:
|
||||||
|
"""Complete result of a ``run_code`` call.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
results: All rich outputs produced by the cell — charts, tables,
|
||||||
|
images, expression values, etc.
|
||||||
|
logs: Captured stdout/stderr text.
|
||||||
|
error: Populated when the cell raised an exception.
|
||||||
|
execution_count: Jupyter execution counter (the ``[N]`` number).
|
||||||
|
"""
|
||||||
|
|
||||||
|
results: list[Result] = field(default_factory=list)
|
||||||
|
logs: Logs = field(default_factory=Logs)
|
||||||
|
error: ExecutionError | None = None
|
||||||
|
execution_count: int | None = None
|
||||||
|
timed_out: bool = False
|
||||||
|
"""``True`` when execution was cut short by the ``timeout`` parameter
|
||||||
|
(or by the kernel WebSocket dropping). Pairs with ``error`` of name
|
||||||
|
``"Timeout"`` or ``"Disconnected"``."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str | None:
|
||||||
|
"""Convenience — ``text/plain`` of the main ``execute_result``,
|
||||||
|
or ``None`` if the cell had no expression value."""
|
||||||
|
for r in self.results:
|
||||||
|
if r.is_main_result:
|
||||||
|
return r.text
|
||||||
|
return None
|
||||||
@ -12,6 +12,11 @@ import httpx_ws
|
|||||||
|
|
||||||
from wrenn.exceptions import handle_response
|
from wrenn.exceptions import handle_response
|
||||||
|
|
||||||
|
# Both signal a terminated WebSocket: ``WebSocketDisconnect`` is a clean close,
|
||||||
|
# ``WebSocketNetworkError`` an abrupt one. The Wrenn server closes exec/process
|
||||||
|
# streams abruptly, so iterators must treat either as end-of-stream.
|
||||||
|
_WS_CLOSED = (httpx_ws.WebSocketDisconnect, httpx_ws.WebSocketNetworkError)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CommandResult:
|
class CommandResult:
|
||||||
@ -106,6 +111,54 @@ def _parse_stream_event(raw: dict) -> StreamEvent:
|
|||||||
return StreamEvent(type=t or "unknown")
|
return StreamEvent(type=t or "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_exec_payload(
|
||||||
|
cmd: str,
|
||||||
|
background: bool,
|
||||||
|
timeout: int | None,
|
||||||
|
envs: dict[str, str] | None,
|
||||||
|
cwd: str | None,
|
||||||
|
tag: str | None,
|
||||||
|
) -> dict:
|
||||||
|
payload: dict = {
|
||||||
|
"cmd": "/bin/sh",
|
||||||
|
"args": ["-c", cmd],
|
||||||
|
"background": background,
|
||||||
|
}
|
||||||
|
if timeout is not None and not background:
|
||||||
|
payload["timeout_sec"] = timeout
|
||||||
|
if envs is not None:
|
||||||
|
payload["envs"] = envs
|
||||||
|
if cwd is not None:
|
||||||
|
payload["cwd"] = cwd
|
||||||
|
if tag is not None:
|
||||||
|
payload["tag"] = tag
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_http_timeout(background: bool, timeout: int | None) -> httpx.Timeout | None:
|
||||||
|
if not background and timeout is not None:
|
||||||
|
return httpx.Timeout(timeout + 10, connect=5.0)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_exec_run(
|
||||||
|
data: dict, capsule_id: str, background: bool
|
||||||
|
) -> CommandResult | CommandHandle:
|
||||||
|
if background:
|
||||||
|
return CommandHandle(
|
||||||
|
pid=data.get("pid", 0),
|
||||||
|
tag=data.get("tag", ""),
|
||||||
|
capsule_id=capsule_id,
|
||||||
|
)
|
||||||
|
return _decode_exec_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_stream_start(cmd: str, args: builtins.list[str] | None) -> dict:
|
||||||
|
if args:
|
||||||
|
return {"type": "start", "cmd": cmd, "args": args}
|
||||||
|
return {"type": "start", "cmd": "/bin/sh", "args": ["-c", cmd]}
|
||||||
|
|
||||||
|
|
||||||
def _decode_exec_response(data: dict) -> CommandResult:
|
def _decode_exec_response(data: dict) -> CommandResult:
|
||||||
stdout = data.get("stdout") or ""
|
stdout = data.get("stdout") or ""
|
||||||
stderr = data.get("stderr") or ""
|
stderr = data.get("stderr") or ""
|
||||||
@ -184,39 +237,14 @@ class Commands:
|
|||||||
CommandHandle: PID and tag for background commands
|
CommandHandle: PID and tag for background commands
|
||||||
(``background=True``).
|
(``background=True``).
|
||||||
"""
|
"""
|
||||||
payload: dict = {
|
|
||||||
"cmd": "/bin/sh",
|
|
||||||
"args": ["-c", cmd],
|
|
||||||
"background": background,
|
|
||||||
}
|
|
||||||
if timeout is not None and not background:
|
|
||||||
payload["timeout_sec"] = timeout
|
|
||||||
if envs is not None:
|
|
||||||
payload["envs"] = envs
|
|
||||||
if cwd is not None:
|
|
||||||
payload["cwd"] = cwd
|
|
||||||
if tag is not None:
|
|
||||||
payload["tag"] = tag
|
|
||||||
|
|
||||||
http_timeout: httpx.Timeout | None = None
|
|
||||||
if not background and timeout is not None:
|
|
||||||
http_timeout = httpx.Timeout(timeout + 10, connect=5.0)
|
|
||||||
|
|
||||||
resp = self._http.post(
|
resp = self._http.post(
|
||||||
f"/v1/capsules/{self._capsule_id}/exec",
|
f"/v1/capsules/{self._capsule_id}/exec",
|
||||||
json=payload,
|
json=_build_exec_payload(cmd, background, timeout, envs, cwd, tag),
|
||||||
timeout=http_timeout,
|
timeout=_exec_http_timeout(background, timeout),
|
||||||
)
|
)
|
||||||
data = handle_response(resp)
|
data = handle_response(resp)
|
||||||
assert isinstance(data, dict)
|
assert isinstance(data, dict)
|
||||||
|
return _decode_exec_run(data, self._capsule_id, background)
|
||||||
if background:
|
|
||||||
return CommandHandle(
|
|
||||||
pid=data.get("pid", 0),
|
|
||||||
tag=data.get("tag", ""),
|
|
||||||
capsule_id=self._capsule_id,
|
|
||||||
)
|
|
||||||
return _decode_exec_response(data)
|
|
||||||
|
|
||||||
def list(self) -> list[ProcessInfo]:
|
def list(self) -> list[ProcessInfo]:
|
||||||
"""List all running background processes in the capsule.
|
"""List all running background processes in the capsule.
|
||||||
@ -271,7 +299,7 @@ class Commands:
|
|||||||
yield event
|
yield event
|
||||||
if event.type in ("exit", "error"):
|
if event.type in ("exit", "error"):
|
||||||
break
|
break
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
break
|
break
|
||||||
|
|
||||||
def stream(
|
def stream(
|
||||||
@ -294,11 +322,7 @@ class Commands:
|
|||||||
f"/v1/capsules/{self._capsule_id}/exec/stream",
|
f"/v1/capsules/{self._capsule_id}/exec/stream",
|
||||||
self._http,
|
self._http,
|
||||||
) as ws: # type: httpx_ws.WebSocketSession
|
) as ws: # type: httpx_ws.WebSocketSession
|
||||||
if args:
|
ws.send_text(json.dumps(_build_stream_start(cmd, args)))
|
||||||
start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
|
|
||||||
else:
|
|
||||||
start_msg = {"type": "start", "cmd": "/bin/sh", "args": ["-c", cmd]}
|
|
||||||
ws.send_text(json.dumps(start_msg))
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
raw = ws.receive_json()
|
raw = ws.receive_json()
|
||||||
@ -306,7 +330,7 @@ class Commands:
|
|||||||
yield event
|
yield event
|
||||||
if event.type in ("exit", "error"):
|
if event.type in ("exit", "error"):
|
||||||
break
|
break
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
@ -373,39 +397,14 @@ class AsyncCommands:
|
|||||||
CommandHandle: PID and tag for background commands
|
CommandHandle: PID and tag for background commands
|
||||||
(``background=True``).
|
(``background=True``).
|
||||||
"""
|
"""
|
||||||
payload: dict = {
|
|
||||||
"cmd": "/bin/sh",
|
|
||||||
"args": ["-c", cmd],
|
|
||||||
"background": background,
|
|
||||||
}
|
|
||||||
if timeout is not None and not background:
|
|
||||||
payload["timeout_sec"] = timeout
|
|
||||||
if envs is not None:
|
|
||||||
payload["envs"] = envs
|
|
||||||
if cwd is not None:
|
|
||||||
payload["cwd"] = cwd
|
|
||||||
if tag is not None:
|
|
||||||
payload["tag"] = tag
|
|
||||||
|
|
||||||
http_timeout: httpx.Timeout | None = None
|
|
||||||
if not background and timeout is not None:
|
|
||||||
http_timeout = httpx.Timeout(timeout + 10, connect=5.0)
|
|
||||||
|
|
||||||
resp = await self._http.post(
|
resp = await self._http.post(
|
||||||
f"/v1/capsules/{self._capsule_id}/exec",
|
f"/v1/capsules/{self._capsule_id}/exec",
|
||||||
json=payload,
|
json=_build_exec_payload(cmd, background, timeout, envs, cwd, tag),
|
||||||
timeout=http_timeout,
|
timeout=_exec_http_timeout(background, timeout),
|
||||||
)
|
)
|
||||||
data = handle_response(resp)
|
data = handle_response(resp)
|
||||||
assert isinstance(data, dict)
|
assert isinstance(data, dict)
|
||||||
|
return _decode_exec_run(data, self._capsule_id, background)
|
||||||
if background:
|
|
||||||
return CommandHandle(
|
|
||||||
pid=data.get("pid", 0),
|
|
||||||
tag=data.get("tag", ""),
|
|
||||||
capsule_id=self._capsule_id,
|
|
||||||
)
|
|
||||||
return _decode_exec_response(data)
|
|
||||||
|
|
||||||
async def list(self) -> list[ProcessInfo]:
|
async def list(self) -> list[ProcessInfo]:
|
||||||
"""List all running background processes in the capsule.
|
"""List all running background processes in the capsule.
|
||||||
@ -462,7 +461,7 @@ class AsyncCommands:
|
|||||||
yield event
|
yield event
|
||||||
if event.type in ("exit", "error"):
|
if event.type in ("exit", "error"):
|
||||||
break
|
break
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def stream(
|
async def stream(
|
||||||
@ -485,11 +484,7 @@ class AsyncCommands:
|
|||||||
f"/v1/capsules/{self._capsule_id}/exec/stream",
|
f"/v1/capsules/{self._capsule_id}/exec/stream",
|
||||||
self._http,
|
self._http,
|
||||||
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||||
if args:
|
await ws.send_text(json.dumps(_build_stream_start(cmd, args)))
|
||||||
start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
|
|
||||||
else:
|
|
||||||
start_msg = {"type": "start", "cmd": "/bin/sh", "args": ["-c", cmd]}
|
|
||||||
await ws.send_text(json.dumps(start_msg))
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
raw = await ws.receive_json()
|
raw = await ws.receive_json()
|
||||||
@ -497,5 +492,5 @@ class AsyncCommands:
|
|||||||
yield event
|
yield event
|
||||||
if event.type in ("exit", "error"):
|
if event.type in ("exit", "error"):
|
||||||
break
|
break
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -150,6 +150,9 @@ def handle_response(resp: httpx.Response) -> dict | list:
|
|||||||
if resp.status_code == 204:
|
if resp.status_code == 204:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
if not resp.content:
|
||||||
|
return {}
|
||||||
|
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
@ -161,4 +164,17 @@ def __getattr__(name: str) -> type:
|
|||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
return WrennHostHasCapsulesError
|
return WrennHostHasCapsulesError
|
||||||
|
if name in ("GitError", "GitCommandError", "GitAuthError"):
|
||||||
|
from wrenn._git.exceptions import (
|
||||||
|
GitAuthError as _GitAuthError,
|
||||||
|
GitCommandError as _GitCommandError,
|
||||||
|
GitError as _GitError,
|
||||||
|
)
|
||||||
|
|
||||||
|
_m: dict[str, type] = {
|
||||||
|
"GitError": _GitError,
|
||||||
|
"GitCommandError": _GitCommandError,
|
||||||
|
"GitAuthError": _GitAuthError,
|
||||||
|
}
|
||||||
|
return _m[name]
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|||||||
@ -9,6 +9,76 @@ from wrenn.exceptions import WrennNotFoundError, _raise_for_status, handle_respo
|
|||||||
from wrenn.models import FileEntry, ListDirResponse, MakeDirResponse
|
from wrenn.models import FileEntry, ListDirResponse, MakeDirResponse
|
||||||
|
|
||||||
|
|
||||||
|
def _is_already_exists(resp: httpx.Response) -> bool:
|
||||||
|
"""Detect server's already-exists reply across status codes / code strings.
|
||||||
|
|
||||||
|
Server may return 409 with code "conflict"/"already_exists" or wrap
|
||||||
|
"already_exists" inside an "internal" 500 message.
|
||||||
|
"""
|
||||||
|
if resp.status_code < 400:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
err = body.get("error", {}) if isinstance(body, dict) else {}
|
||||||
|
code = err.get("code", "")
|
||||||
|
msg = err.get("message", "") or ""
|
||||||
|
return code in {"conflict", "already_exists"} or "already_exists" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def _find_entry(list_fn, path: str) -> FileEntry | None:
|
||||||
|
parent = os.path.dirname(path)
|
||||||
|
name = os.path.basename(path)
|
||||||
|
try:
|
||||||
|
for entry in list_fn(parent, depth=1):
|
||||||
|
if entry.name == name:
|
||||||
|
return entry
|
||||||
|
except WrennNotFoundError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_find_entry(list_fn, path: str) -> FileEntry | None:
|
||||||
|
parent = os.path.dirname(path)
|
||||||
|
name = os.path.basename(path)
|
||||||
|
try:
|
||||||
|
for entry in await list_fn(parent, depth=1):
|
||||||
|
if entry.name == name:
|
||||||
|
return entry
|
||||||
|
except WrennNotFoundError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_MULTIPART_FILE_HEADER = (
|
||||||
|
b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n'
|
||||||
|
b"Content-Type: application/octet-stream\r\n\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _multipart_frame(path: str, boundary: bytes) -> tuple[bytes, bytes]:
|
||||||
|
"""Return (preamble, trailer) bytes wrapping the file body chunks."""
|
||||||
|
preamble = (
|
||||||
|
b"--" + boundary + b"\r\n"
|
||||||
|
b'Content-Disposition: form-data; name="path"\r\n\r\n'
|
||||||
|
+ path.encode("utf-8")
|
||||||
|
+ b"\r\n--"
|
||||||
|
+ boundary
|
||||||
|
+ b"\r\n"
|
||||||
|
+ _MULTIPART_FILE_HEADER
|
||||||
|
)
|
||||||
|
trailer = b"\r\n--" + boundary + b"--\r\n"
|
||||||
|
return preamble, trailer
|
||||||
|
|
||||||
|
|
||||||
|
def _multipart_headers(boundary: bytes) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}",
|
||||||
|
"Transfer-Encoding": "chunked",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Files:
|
class Files:
|
||||||
"""Sync filesystem interface. Accessed via ``capsule.files``."""
|
"""Sync filesystem interface. Accessed via ``capsule.files``."""
|
||||||
|
|
||||||
@ -118,17 +188,10 @@ class Files:
|
|||||||
f"/v1/capsules/{self._capsule_id}/files/mkdir",
|
f"/v1/capsules/{self._capsule_id}/files/mkdir",
|
||||||
json={"path": path},
|
json={"path": path},
|
||||||
)
|
)
|
||||||
if resp.status_code == 409:
|
if _is_already_exists(resp):
|
||||||
try:
|
existing = _find_entry(self.list, path)
|
||||||
body = resp.json()
|
if existing is not None:
|
||||||
if body.get("error", {}).get("code") == "conflict":
|
return existing
|
||||||
parent = os.path.dirname(path)
|
|
||||||
name = os.path.basename(path)
|
|
||||||
for entry in self.list(parent, depth=1):
|
|
||||||
if entry.name == name:
|
|
||||||
return entry
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
parsed = MakeDirResponse.model_validate(handle_response(resp))
|
parsed = MakeDirResponse.model_validate(handle_response(resp))
|
||||||
if parsed.entry is None:
|
if parsed.entry is None:
|
||||||
raise RuntimeError("mkdir response missing entry")
|
raise RuntimeError("mkdir response missing entry")
|
||||||
@ -160,24 +223,18 @@ class Files:
|
|||||||
stream (Iterator[bytes]): Iterable of byte chunks to upload.
|
stream (Iterator[bytes]): Iterable of byte chunks to upload.
|
||||||
"""
|
"""
|
||||||
boundary = os.urandom(16).hex().encode("utf-8")
|
boundary = os.urandom(16).hex().encode("utf-8")
|
||||||
|
preamble, trailer = _multipart_frame(path, boundary)
|
||||||
|
|
||||||
def _multipart() -> Iterator[bytes]:
|
def _multipart() -> Iterator[bytes]:
|
||||||
yield b"--" + boundary + b"\r\n"
|
yield preamble
|
||||||
yield b'Content-Disposition: form-data; name="path"\r\n\r\n'
|
|
||||||
yield path.encode("utf-8") + b"\r\n"
|
|
||||||
yield b"--" + boundary + b"\r\n"
|
|
||||||
yield b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n'
|
|
||||||
yield b"Content-Type: application/octet-stream\r\n\r\n"
|
|
||||||
for chunk in stream:
|
for chunk in stream:
|
||||||
yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8")
|
yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8")
|
||||||
yield b"\r\n--" + boundary + b"--\r\n"
|
yield trailer
|
||||||
|
|
||||||
resp = self._http.post(
|
resp = self._http.post(
|
||||||
f"/v1/capsules/{self._capsule_id}/files/stream/write",
|
f"/v1/capsules/{self._capsule_id}/files/stream/write",
|
||||||
content=_multipart(),
|
content=_multipart(),
|
||||||
headers={
|
headers=_multipart_headers(boundary),
|
||||||
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}"
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
_raise_for_status(resp)
|
_raise_for_status(resp)
|
||||||
|
|
||||||
@ -315,17 +372,10 @@ class AsyncFiles:
|
|||||||
f"/v1/capsules/{self._capsule_id}/files/mkdir",
|
f"/v1/capsules/{self._capsule_id}/files/mkdir",
|
||||||
json={"path": path},
|
json={"path": path},
|
||||||
)
|
)
|
||||||
if resp.status_code == 409:
|
if _is_already_exists(resp):
|
||||||
try:
|
existing = await _async_find_entry(self.list, path)
|
||||||
body = resp.json()
|
if existing is not None:
|
||||||
if body.get("error", {}).get("code") == "conflict":
|
return existing
|
||||||
parent = os.path.dirname(path)
|
|
||||||
name = os.path.basename(path)
|
|
||||||
for entry in await self.list(parent, depth=1):
|
|
||||||
if entry.name == name:
|
|
||||||
return entry
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
parsed = MakeDirResponse.model_validate(handle_response(resp))
|
parsed = MakeDirResponse.model_validate(handle_response(resp))
|
||||||
if parsed.entry is None:
|
if parsed.entry is None:
|
||||||
raise RuntimeError("mkdir response missing entry")
|
raise RuntimeError("mkdir response missing entry")
|
||||||
@ -358,24 +408,18 @@ class AsyncFiles:
|
|||||||
upload.
|
upload.
|
||||||
"""
|
"""
|
||||||
boundary = os.urandom(16).hex().encode("utf-8")
|
boundary = os.urandom(16).hex().encode("utf-8")
|
||||||
|
preamble, trailer = _multipart_frame(path, boundary)
|
||||||
|
|
||||||
async def _multipart() -> AsyncIterator[bytes]:
|
async def _multipart() -> AsyncIterator[bytes]:
|
||||||
yield b"--" + boundary + b"\r\n"
|
yield preamble
|
||||||
yield b'Content-Disposition: form-data; name="path"\r\n\r\n'
|
|
||||||
yield path.encode("utf-8") + b"\r\n"
|
|
||||||
yield b"--" + boundary + b"\r\n"
|
|
||||||
yield b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n'
|
|
||||||
yield b"Content-Type: application/octet-stream\r\n\r\n"
|
|
||||||
async for chunk in stream:
|
async for chunk in stream:
|
||||||
yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8")
|
yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8")
|
||||||
yield b"\r\n--" + boundary + b"--\r\n"
|
yield trailer
|
||||||
|
|
||||||
resp = await self._http.post(
|
resp = await self._http.post(
|
||||||
f"/v1/capsules/{self._capsule_id}/files/stream/write",
|
f"/v1/capsules/{self._capsule_id}/files/stream/write",
|
||||||
content=_multipart(),
|
content=_multipart(),
|
||||||
headers={
|
headers=_multipart_headers(boundary),
|
||||||
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}"
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
_raise_for_status(resp)
|
_raise_for_status(resp)
|
||||||
|
|
||||||
|
|||||||
@ -1,67 +1,17 @@
|
|||||||
from wrenn.models._generated import (
|
from wrenn.models._generated import (
|
||||||
APIKeyResponse,
|
|
||||||
AuthResponse,
|
|
||||||
Capsule,
|
Capsule,
|
||||||
CreateAPIKeyRequest,
|
|
||||||
CreateCapsuleRequest,
|
|
||||||
CreateHostRequest,
|
|
||||||
CreateHostResponse,
|
|
||||||
CreateSnapshotRequest,
|
|
||||||
Encoding,
|
|
||||||
Error,
|
|
||||||
Error1,
|
|
||||||
ExecRequest,
|
|
||||||
ExecResponse,
|
|
||||||
FileEntry,
|
FileEntry,
|
||||||
Host,
|
|
||||||
ListDirRequest,
|
|
||||||
ListDirResponse,
|
ListDirResponse,
|
||||||
LoginRequest,
|
|
||||||
MakeDirRequest,
|
|
||||||
MakeDirResponse,
|
MakeDirResponse,
|
||||||
ReadFileRequest,
|
|
||||||
RegisterHostRequest,
|
|
||||||
RegisterHostResponse,
|
|
||||||
RemoveRequest,
|
|
||||||
SignupRequest,
|
|
||||||
Status,
|
Status,
|
||||||
Status1,
|
|
||||||
Template,
|
Template,
|
||||||
Type,
|
|
||||||
Type1,
|
|
||||||
Type2,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"APIKeyResponse",
|
|
||||||
"AuthResponse",
|
|
||||||
"CreateAPIKeyRequest",
|
|
||||||
"CreateHostRequest",
|
|
||||||
"CreateHostResponse",
|
|
||||||
"CreateCapsuleRequest",
|
|
||||||
"CreateSnapshotRequest",
|
|
||||||
"Encoding",
|
|
||||||
"Error",
|
|
||||||
"Error1",
|
|
||||||
"ExecRequest",
|
|
||||||
"ExecResponse",
|
|
||||||
"FileEntry",
|
|
||||||
"Host",
|
|
||||||
"ListDirRequest",
|
|
||||||
"ListDirResponse",
|
|
||||||
"LoginRequest",
|
|
||||||
"MakeDirRequest",
|
|
||||||
"MakeDirResponse",
|
|
||||||
"ReadFileRequest",
|
|
||||||
"RegisterHostRequest",
|
|
||||||
"RegisterHostResponse",
|
|
||||||
"RemoveRequest",
|
|
||||||
"Capsule",
|
"Capsule",
|
||||||
"SignupRequest",
|
"FileEntry",
|
||||||
|
"ListDirResponse",
|
||||||
|
"MakeDirResponse",
|
||||||
"Status",
|
"Status",
|
||||||
"Status1",
|
|
||||||
"Template",
|
"Template",
|
||||||
"Type",
|
|
||||||
"Type1",
|
|
||||||
"Type2",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,139 +1,22 @@
|
|||||||
# generated by datamodel-codegen:
|
# generated by datamodel-codegen:
|
||||||
# filename: openapi.yaml
|
# filename: openapi.yaml
|
||||||
# timestamp: 2026-04-22T20:21:34+00:00
|
# timestamp: 2026-05-23T11:20:02+00:00
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
|
from pydantic import AwareDatetime, BaseModel, Field
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from datetime import date as date_aliased
|
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
class SignupRequest(BaseModel):
|
|
||||||
email: EmailStr
|
|
||||||
password: Annotated[str, Field(min_length=8)]
|
|
||||||
name: Annotated[str, Field(max_length=100)]
|
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
|
||||||
email: EmailStr
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
class SignupResponse(BaseModel):
|
|
||||||
message: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Confirmation message instructing user to check email"),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AuthResponse(BaseModel):
|
|
||||||
token: Annotated[str | None, Field(description="JWT token (valid for 6 hours)")] = (
|
|
||||||
None
|
|
||||||
)
|
|
||||||
user_id: str | None = None
|
|
||||||
team_id: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
name: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CreateAPIKeyRequest(BaseModel):
|
|
||||||
name: str | None = "Unnamed API Key"
|
|
||||||
|
|
||||||
|
|
||||||
class APIKeyResponse(BaseModel):
|
|
||||||
id: str | None = None
|
|
||||||
team_id: str | None = None
|
|
||||||
name: str | None = None
|
|
||||||
key_prefix: Annotated[
|
|
||||||
str | None, Field(description='Display prefix (e.g. "wrn_ab12cd34...")')
|
|
||||||
] = None
|
|
||||||
created_at: AwareDatetime | None = None
|
|
||||||
last_used: AwareDatetime | None = None
|
|
||||||
key: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(
|
|
||||||
description="Full plaintext key. Only returned on creation, never again."
|
|
||||||
),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class CreateCapsuleRequest(BaseModel):
|
|
||||||
template: str | None = "minimal"
|
|
||||||
vcpus: int | None = 1
|
|
||||||
memory_mb: int | None = 512
|
|
||||||
timeout_sec: Annotated[
|
|
||||||
int | None,
|
|
||||||
Field(
|
|
||||||
description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause.\n"
|
|
||||||
),
|
|
||||||
] = 0
|
|
||||||
|
|
||||||
|
|
||||||
class Point(BaseModel):
|
|
||||||
date: date_aliased | None = None
|
|
||||||
cpu_minutes: float | None = None
|
|
||||||
ram_mb_minutes: float | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class UsageResponse(BaseModel):
|
|
||||||
from_: Annotated[date_aliased | None, Field(alias="from")] = None
|
|
||||||
to: date_aliased | None = None
|
|
||||||
points: list[Point] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Range(StrEnum):
|
|
||||||
field_5m = "5m"
|
|
||||||
field_1h = "1h"
|
|
||||||
field_6h = "6h"
|
|
||||||
field_24h = "24h"
|
|
||||||
field_30d = "30d"
|
|
||||||
|
|
||||||
|
|
||||||
class Current(BaseModel):
|
|
||||||
running_count: int | None = None
|
|
||||||
vcpus_reserved: int | None = None
|
|
||||||
memory_mb_reserved: int | None = None
|
|
||||||
sampled_at: AwareDatetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Peaks(BaseModel):
|
|
||||||
"""
|
|
||||||
Maximum values over the last 30 days.
|
|
||||||
"""
|
|
||||||
|
|
||||||
running_count: int | None = None
|
|
||||||
vcpus: int | None = None
|
|
||||||
memory_mb: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Series(BaseModel):
|
|
||||||
"""
|
|
||||||
Parallel arrays for chart rendering.
|
|
||||||
"""
|
|
||||||
|
|
||||||
labels: list[AwareDatetime] | None = None
|
|
||||||
running: list[int] | None = None
|
|
||||||
vcpus: list[int] | None = None
|
|
||||||
memory_mb: list[int] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CapsuleStats(BaseModel):
|
|
||||||
range: Range | None = None
|
|
||||||
current: Current | None = None
|
|
||||||
peaks: Annotated[
|
|
||||||
Peaks | None, Field(description="Maximum values over the last 30 days.")
|
|
||||||
] = None
|
|
||||||
series: Annotated[
|
|
||||||
Series | None, Field(description="Parallel arrays for chart rendering.")
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Status(StrEnum):
|
class Status(StrEnum):
|
||||||
pending = "pending"
|
pending = "pending"
|
||||||
starting = "starting"
|
starting = "starting"
|
||||||
running = "running"
|
running = "running"
|
||||||
|
pausing = "pausing"
|
||||||
paused = "paused"
|
paused = "paused"
|
||||||
|
snapshotting = "snapshotting"
|
||||||
|
resuming = "resuming"
|
||||||
|
stopping = "stopping"
|
||||||
hibernated = "hibernated"
|
hibernated = "hibernated"
|
||||||
stopped = "stopped"
|
stopped = "stopped"
|
||||||
missing = "missing"
|
missing = "missing"
|
||||||
@ -147,21 +30,24 @@ class Capsule(BaseModel):
|
|||||||
vcpus: int | None = None
|
vcpus: int | None = None
|
||||||
memory_mb: int | None = None
|
memory_mb: int | None = None
|
||||||
timeout_sec: int | None = None
|
timeout_sec: int | None = None
|
||||||
guest_ip: str | None = None
|
|
||||||
host_ip: str | None = None
|
|
||||||
created_at: AwareDatetime | None = None
|
created_at: AwareDatetime | None = None
|
||||||
started_at: AwareDatetime | None = None
|
started_at: AwareDatetime | None = None
|
||||||
last_active_at: AwareDatetime | None = None
|
last_active_at: AwareDatetime | None = None
|
||||||
last_updated: AwareDatetime | None = None
|
last_updated: AwareDatetime | None = None
|
||||||
|
metadata: Annotated[
|
||||||
|
dict[str, str] | None,
|
||||||
class CreateSnapshotRequest(BaseModel):
|
Field(
|
||||||
sandbox_id: Annotated[
|
description="Free-form key/value labels attached at create-time. Also carries\nagent-side version info (kernel_version, vmm_version,\nagent_version, envd_version) when running.\n"
|
||||||
str, Field(description="ID of the running capsule to snapshot.")
|
),
|
||||||
]
|
] = None
|
||||||
name: Annotated[
|
disk_size_mb: Annotated[
|
||||||
str | None,
|
int | None, Field(description="Maximum disk capacity in MiB.")
|
||||||
Field(description="Name for the snapshot template. Auto-generated if omitted."),
|
] = None
|
||||||
|
disk_used_mb: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(
|
||||||
|
description="Current disk usage in MiB. Only populated on individual capsule GET; omitted in list responses."
|
||||||
|
),
|
||||||
] = None
|
] = None
|
||||||
|
|
||||||
|
|
||||||
@ -177,96 +63,22 @@ class Template(BaseModel):
|
|||||||
memory_mb: int | None = None
|
memory_mb: int | None = None
|
||||||
size_bytes: int | None = None
|
size_bytes: int | None = None
|
||||||
created_at: AwareDatetime | None = None
|
created_at: AwareDatetime | None = None
|
||||||
|
platform: Annotated[
|
||||||
|
|
||||||
class ExecRequest(BaseModel):
|
|
||||||
cmd: str
|
|
||||||
args: list[str] | None = None
|
|
||||||
timeout_sec: Annotated[
|
|
||||||
int | None,
|
|
||||||
Field(description="Timeout in seconds (foreground exec only, default 30)"),
|
|
||||||
] = 30
|
|
||||||
background: Annotated[
|
|
||||||
bool | None,
|
bool | None,
|
||||||
Field(
|
Field(
|
||||||
description="If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202)"
|
description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal-ubuntu` rootfs). False for team-owned\nsnapshot templates.\n"
|
||||||
),
|
|
||||||
] = False
|
|
||||||
tag: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(
|
|
||||||
description="Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true."
|
|
||||||
),
|
),
|
||||||
] = None
|
] = None
|
||||||
envs: Annotated[
|
protected: Annotated[
|
||||||
dict[str, str] | None,
|
bool | None,
|
||||||
Field(
|
Field(
|
||||||
description="Environment variables for the process (background exec only)"
|
description="True for built-in system base templates (minimal-ubuntu,\nminimal-alpine, minimal-arch, minimal-fedora). Protected templates\ncannot be deleted.\n"
|
||||||
),
|
),
|
||||||
] = None
|
] = None
|
||||||
cwd: Annotated[
|
metadata: dict[str, str] | None = None
|
||||||
str | None,
|
|
||||||
Field(description="Working directory for the process (background exec only)"),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class BackgroundExecResponse(BaseModel):
|
class Type2(StrEnum):
|
||||||
sandbox_id: str | None = None
|
|
||||||
cmd: str | None = None
|
|
||||||
pid: int | None = None
|
|
||||||
tag: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessEntry(BaseModel):
|
|
||||||
pid: int | None = None
|
|
||||||
tag: str | None = None
|
|
||||||
cmd: str | None = None
|
|
||||||
args: list[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessListResponse(BaseModel):
|
|
||||||
processes: list[ProcessEntry] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Encoding(StrEnum):
|
|
||||||
"""
|
|
||||||
Output encoding. "base64" when stdout/stderr contain binary data.
|
|
||||||
"""
|
|
||||||
|
|
||||||
utf_8 = "utf-8"
|
|
||||||
base64 = "base64"
|
|
||||||
|
|
||||||
|
|
||||||
class ExecResponse(BaseModel):
|
|
||||||
sandbox_id: str | None = None
|
|
||||||
cmd: str | None = None
|
|
||||||
stdout: str | None = None
|
|
||||||
stderr: str | None = None
|
|
||||||
exit_code: int | None = None
|
|
||||||
duration_ms: int | None = None
|
|
||||||
encoding: Annotated[
|
|
||||||
Encoding | None,
|
|
||||||
Field(
|
|
||||||
description='Output encoding. "base64" when stdout/stderr contain binary data.'
|
|
||||||
),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ReadFileRequest(BaseModel):
|
|
||||||
path: Annotated[str, Field(description="Absolute file path inside the capsule")]
|
|
||||||
|
|
||||||
|
|
||||||
class ListDirRequest(BaseModel):
|
|
||||||
path: Annotated[str, Field(description="Directory path inside the capsule")]
|
|
||||||
depth: Annotated[
|
|
||||||
int | None,
|
|
||||||
Field(
|
|
||||||
description="Recursion depth (0 = non-recursive, 1 = immediate children)"
|
|
||||||
),
|
|
||||||
] = 1
|
|
||||||
|
|
||||||
|
|
||||||
class Type1(StrEnum):
|
|
||||||
file = "file"
|
file = "file"
|
||||||
directory = "directory"
|
directory = "directory"
|
||||||
symlink = "symlink"
|
symlink = "symlink"
|
||||||
@ -275,7 +87,7 @@ class Type1(StrEnum):
|
|||||||
class FileEntry(BaseModel):
|
class FileEntry(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
path: str | None = None
|
path: str | None = None
|
||||||
type: Type1 | None = None
|
type: Type2 | None = None
|
||||||
size: int | None = None
|
size: int | None = None
|
||||||
mode: int | None = None
|
mode: int | None = None
|
||||||
permissions: Annotated[
|
permissions: Annotated[
|
||||||
@ -289,337 +101,9 @@ class FileEntry(BaseModel):
|
|||||||
symlink_target: str | None = None
|
symlink_target: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class MakeDirRequest(BaseModel):
|
|
||||||
path: Annotated[
|
|
||||||
str, Field(description="Directory path to create inside the capsule")
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class MakeDirResponse(BaseModel):
|
class MakeDirResponse(BaseModel):
|
||||||
entry: FileEntry | None = None
|
entry: FileEntry | None = None
|
||||||
|
|
||||||
|
|
||||||
class RemoveRequest(BaseModel):
|
|
||||||
path: Annotated[str, Field(description="Path to remove inside the capsule")]
|
|
||||||
|
|
||||||
|
|
||||||
class Type2(StrEnum):
|
|
||||||
"""
|
|
||||||
Host type. Regular hosts are shared; BYOC hosts belong to a team.
|
|
||||||
"""
|
|
||||||
|
|
||||||
regular = "regular"
|
|
||||||
byoc = "byoc"
|
|
||||||
|
|
||||||
|
|
||||||
class CreateHostRequest(BaseModel):
|
|
||||||
type: Annotated[
|
|
||||||
Type2,
|
|
||||||
Field(
|
|
||||||
description="Host type. Regular hosts are shared; BYOC hosts belong to a team."
|
|
||||||
),
|
|
||||||
]
|
|
||||||
team_id: Annotated[str | None, Field(description="Required for BYOC hosts.")] = None
|
|
||||||
provider: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Cloud provider (e.g. aws, gcp, hetzner, bare-metal)."),
|
|
||||||
] = None
|
|
||||||
availability_zone: Annotated[
|
|
||||||
str | None, Field(description="Availability zone (e.g. us-east, eu-west).")
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterHostRequest(BaseModel):
|
|
||||||
token: Annotated[
|
|
||||||
str, Field(description="One-time registration token from POST /v1/hosts.")
|
|
||||||
]
|
|
||||||
arch: Annotated[
|
|
||||||
str | None, Field(description="CPU architecture (e.g. x86_64, aarch64).")
|
|
||||||
] = None
|
|
||||||
cpu_cores: int | None = None
|
|
||||||
memory_mb: int | None = None
|
|
||||||
disk_gb: int | None = None
|
|
||||||
address: Annotated[str, Field(description="Host agent address (ip:port).")]
|
|
||||||
|
|
||||||
|
|
||||||
class Type3(StrEnum):
|
|
||||||
regular = "regular"
|
|
||||||
byoc = "byoc"
|
|
||||||
|
|
||||||
|
|
||||||
class Status1(StrEnum):
|
|
||||||
pending = "pending"
|
|
||||||
online = "online"
|
|
||||||
offline = "offline"
|
|
||||||
draining = "draining"
|
|
||||||
unreachable = "unreachable"
|
|
||||||
|
|
||||||
|
|
||||||
class Host(BaseModel):
|
|
||||||
id: str | None = None
|
|
||||||
type: Type3 | None = None
|
|
||||||
team_id: str | None = None
|
|
||||||
provider: str | None = None
|
|
||||||
availability_zone: str | None = None
|
|
||||||
arch: str | None = None
|
|
||||||
cpu_cores: int | None = None
|
|
||||||
memory_mb: int | None = None
|
|
||||||
disk_gb: int | None = None
|
|
||||||
address: str | None = None
|
|
||||||
status: Status1 | None = None
|
|
||||||
last_heartbeat_at: AwareDatetime | None = None
|
|
||||||
created_by: str | None = None
|
|
||||||
created_at: AwareDatetime | None = None
|
|
||||||
updated_at: AwareDatetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RefreshHostTokenRequest(BaseModel):
|
|
||||||
refresh_token: Annotated[
|
|
||||||
str,
|
|
||||||
Field(
|
|
||||||
description="Refresh token obtained from registration or a previous refresh."
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class RefreshHostTokenResponse(BaseModel):
|
|
||||||
host: Host | None = None
|
|
||||||
token: Annotated[
|
|
||||||
str | None, Field(description="New host JWT. Valid for 7 days.")
|
|
||||||
] = None
|
|
||||||
refresh_token: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(
|
|
||||||
description="New refresh token. Valid for 60 days; old token is revoked."
|
|
||||||
),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class HostDeletePreview(BaseModel):
|
|
||||||
host: Host | None = None
|
|
||||||
sandbox_ids: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(description="IDs of capsulees that would be destroyed on force-delete."),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Error(BaseModel):
|
|
||||||
code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None
|
|
||||||
message: str | None = None
|
|
||||||
sandbox_ids: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(description="IDs of active capsulees blocking deletion."),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class HostHasCapsulesError(BaseModel):
|
|
||||||
error: Error | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class AddTagRequest(BaseModel):
|
|
||||||
tag: str
|
|
||||||
|
|
||||||
|
|
||||||
class UserSearchResult(BaseModel):
|
|
||||||
user_id: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Team(BaseModel):
|
|
||||||
id: str | None = None
|
|
||||||
name: str | None = None
|
|
||||||
slug: Annotated[
|
|
||||||
str | None, Field(description="Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)")
|
|
||||||
] = None
|
|
||||||
created_at: AwareDatetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Role(StrEnum):
|
|
||||||
owner = "owner"
|
|
||||||
admin = "admin"
|
|
||||||
member = "member"
|
|
||||||
|
|
||||||
|
|
||||||
class TeamWithRole(Team):
|
|
||||||
role: Role | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class TeamMember(BaseModel):
|
|
||||||
user_id: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
role: Role | None = None
|
|
||||||
joined_at: AwareDatetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class TeamDetail(BaseModel):
|
|
||||||
team: Team | None = None
|
|
||||||
members: list[TeamMember] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Range1(StrEnum):
|
|
||||||
field_5m = "5m"
|
|
||||||
field_10m = "10m"
|
|
||||||
field_1h = "1h"
|
|
||||||
field_2h = "2h"
|
|
||||||
field_6h = "6h"
|
|
||||||
field_12h = "12h"
|
|
||||||
field_24h = "24h"
|
|
||||||
|
|
||||||
|
|
||||||
class MetricPoint(BaseModel):
|
|
||||||
timestamp_unix: int | None = None
|
|
||||||
cpu_pct: Annotated[
|
|
||||||
float | None,
|
|
||||||
Field(
|
|
||||||
description="CPU utilization percentage (0-100), normalized to vCPU count"
|
|
||||||
),
|
|
||||||
] = None
|
|
||||||
mem_bytes: Annotated[
|
|
||||||
int | None,
|
|
||||||
Field(description="Resident memory in bytes (VmRSS of Firecracker process)"),
|
|
||||||
] = None
|
|
||||||
disk_bytes: Annotated[
|
|
||||||
int | None, Field(description="Allocated disk bytes for the CoW sparse file")
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Provider(StrEnum):
|
|
||||||
discord = "discord"
|
|
||||||
slack = "slack"
|
|
||||||
teams = "teams"
|
|
||||||
googlechat = "googlechat"
|
|
||||||
telegram = "telegram"
|
|
||||||
matrix = "matrix"
|
|
||||||
webhook = "webhook"
|
|
||||||
|
|
||||||
|
|
||||||
class Event(StrEnum):
|
|
||||||
capsule_created = "capsule.created"
|
|
||||||
capsule_running = "capsule.running"
|
|
||||||
capsule_paused = "capsule.paused"
|
|
||||||
capsule_destroyed = "capsule.destroyed"
|
|
||||||
template_snapshot_created = "template.snapshot.created"
|
|
||||||
template_snapshot_deleted = "template.snapshot.deleted"
|
|
||||||
host_up = "host.up"
|
|
||||||
host_down = "host.down"
|
|
||||||
|
|
||||||
|
|
||||||
class CreateChannelRequest(BaseModel):
|
|
||||||
name: Annotated[str, Field(description="Unique channel name within the team.")]
|
|
||||||
provider: Provider
|
|
||||||
config: Annotated[
|
|
||||||
dict[str, str],
|
|
||||||
Field(
|
|
||||||
description='Provider-specific configuration fields. Discord/Slack/Teams/Google Chat: {"webhook_url": "..."}. Telegram: {"bot_token": "...", "chat_id": "..."}. Matrix: {"homeserver_url": "...", "access_token": "...", "room_id": "..."}. Webhook: {"url": "...", "secret": "..."} (secret is auto-generated if omitted).\n'
|
|
||||||
),
|
|
||||||
]
|
|
||||||
events: list[Event]
|
|
||||||
|
|
||||||
|
|
||||||
class TestChannelRequest(BaseModel):
|
|
||||||
provider: Provider
|
|
||||||
config: Annotated[
|
|
||||||
dict[str, str],
|
|
||||||
Field(
|
|
||||||
description="Provider-specific configuration fields (same as CreateChannelRequest.config)."
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class RotateConfigRequest(BaseModel):
|
|
||||||
config: Annotated[
|
|
||||||
dict[str, str],
|
|
||||||
Field(
|
|
||||||
description="New provider configuration fields. Must include all required fields for the channel's provider. Replaces the existing config entirely.\n"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateChannelRequest(BaseModel):
|
|
||||||
name: str
|
|
||||||
events: list[Event]
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelResponse(BaseModel):
|
|
||||||
id: str | None = None
|
|
||||||
team_id: str | None = None
|
|
||||||
name: str | None = None
|
|
||||||
provider: Provider | None = None
|
|
||||||
events: list[str] | None = None
|
|
||||||
created_at: AwareDatetime | None = None
|
|
||||||
updated_at: AwareDatetime | None = None
|
|
||||||
secret: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Webhook secret. Only returned on creation, never again."),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class MeResponse(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
email: EmailStr | None = None
|
|
||||||
has_password: Annotated[
|
|
||||||
bool | None,
|
|
||||||
Field(
|
|
||||||
description="Whether the user has a password set (false for OAuth-only accounts)"
|
|
||||||
),
|
|
||||||
] = None
|
|
||||||
providers: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(description='List of linked OAuth provider names (e.g. ["github"])'),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordRequest(BaseModel):
|
|
||||||
current_password: Annotated[
|
|
||||||
str | None, Field(description="Required when changing an existing password")
|
|
||||||
] = None
|
|
||||||
new_password: Annotated[str, Field(min_length=8)]
|
|
||||||
confirm_password: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(
|
|
||||||
description="Required when adding a password to an OAuth-only account (must match new_password)"
|
|
||||||
),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Error2(BaseModel):
|
|
||||||
code: str | None = None
|
|
||||||
message: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Error1(BaseModel):
|
|
||||||
error: Error2 | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ListDirResponse(BaseModel):
|
class ListDirResponse(BaseModel):
|
||||||
entries: list[FileEntry] | None = None
|
entries: list[FileEntry] | None = None
|
||||||
|
|
||||||
|
|
||||||
class CreateHostResponse(BaseModel):
|
|
||||||
host: Host | None = None
|
|
||||||
registration_token: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(
|
|
||||||
description="One-time registration token for the host agent. Expires in 1 hour."
|
|
||||||
),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterHostResponse(BaseModel):
|
|
||||||
host: Host | None = None
|
|
||||||
token: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Host JWT for X-Host-Token header. Valid for 7 days."),
|
|
||||||
] = None
|
|
||||||
refresh_token: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(
|
|
||||||
description="Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use."
|
|
||||||
),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class CapsuleMetrics(BaseModel):
|
|
||||||
sandbox_id: str | None = None
|
|
||||||
range: Range1 | None = None
|
|
||||||
points: list[MetricPoint] | None = None
|
|
||||||
|
|||||||
@ -9,6 +9,10 @@ from typing import Any
|
|||||||
import httpx_ws
|
import httpx_ws
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
# A clean (``WebSocketDisconnect``) or abrupt (``WebSocketNetworkError``) close
|
||||||
|
# both mean the PTY stream has ended; iteration must stop on either.
|
||||||
|
_WS_CLOSED = (httpx_ws.WebSocketDisconnect, httpx_ws.WebSocketNetworkError)
|
||||||
|
|
||||||
|
|
||||||
class PtyEventType(StrEnum):
|
class PtyEventType(StrEnum):
|
||||||
started = "started"
|
started = "started"
|
||||||
@ -49,7 +53,16 @@ def _parse_pty_event(raw: dict[str, Any]) -> PtyEvent:
|
|||||||
)
|
)
|
||||||
if msg_type == "ping":
|
if msg_type == "ping":
|
||||||
return PtyEvent(type=PtyEventType.ping)
|
return PtyEvent(type=PtyEventType.ping)
|
||||||
return PtyEvent(type=PtyEventType(msg_type) if msg_type else PtyEventType.ping)
|
if not msg_type:
|
||||||
|
return PtyEvent(type=PtyEventType.ping)
|
||||||
|
try:
|
||||||
|
return PtyEvent(type=PtyEventType(msg_type))
|
||||||
|
except ValueError:
|
||||||
|
return PtyEvent(
|
||||||
|
type=PtyEventType.error,
|
||||||
|
data=f"unknown msg_type: {msg_type!r}",
|
||||||
|
fatal=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PtySession:
|
class PtySession:
|
||||||
@ -109,6 +122,13 @@ class PtySession:
|
|||||||
def _send_connect(self, tag: str) -> None:
|
def _send_connect(self, tag: str) -> None:
|
||||||
self._ws.send_text(json.dumps({"type": "connect", "tag": tag}))
|
self._ws.send_text(json.dumps({"type": "connect", "tag": tag}))
|
||||||
|
|
||||||
|
def _send_pong(self) -> None:
|
||||||
|
"""Reply to a server keepalive ``ping`` so the session stays open."""
|
||||||
|
try:
|
||||||
|
self._ws.send_text(json.dumps({"type": "pong"}))
|
||||||
|
except _WS_CLOSED:
|
||||||
|
pass
|
||||||
|
|
||||||
def write(self, data: bytes) -> None:
|
def write(self, data: bytes) -> None:
|
||||||
"""Send raw bytes to the PTY stdin.
|
"""Send raw bytes to the PTY stdin.
|
||||||
|
|
||||||
@ -144,7 +164,7 @@ class PtySession:
|
|||||||
raise StopIteration
|
raise StopIteration
|
||||||
try:
|
try:
|
||||||
raw = self._ws.receive_text()
|
raw = self._ws.receive_text()
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
raise StopIteration
|
raise StopIteration
|
||||||
event = _parse_pty_event(json.loads(raw))
|
event = _parse_pty_event(json.loads(raw))
|
||||||
if event.type == PtyEventType.started:
|
if event.type == PtyEventType.started:
|
||||||
@ -152,6 +172,8 @@ class PtySession:
|
|||||||
self._tag = event.tag
|
self._tag = event.tag
|
||||||
if event.pid is not None:
|
if event.pid is not None:
|
||||||
self._pid = event.pid
|
self._pid = event.pid
|
||||||
|
if event.type == PtyEventType.ping:
|
||||||
|
self._send_pong()
|
||||||
if event.type == PtyEventType.exit:
|
if event.type == PtyEventType.exit:
|
||||||
self._done = True
|
self._done = True
|
||||||
return event
|
return event
|
||||||
@ -236,6 +258,13 @@ class AsyncPtySession:
|
|||||||
async def _send_connect(self, tag: str) -> None:
|
async def _send_connect(self, tag: str) -> None:
|
||||||
await self._ws.send_text(json.dumps({"type": "connect", "tag": tag}))
|
await self._ws.send_text(json.dumps({"type": "connect", "tag": tag}))
|
||||||
|
|
||||||
|
async def _send_pong(self) -> None:
|
||||||
|
"""Reply to a server keepalive ``ping`` so the session stays open."""
|
||||||
|
try:
|
||||||
|
await self._ws.send_text(json.dumps({"type": "pong"}))
|
||||||
|
except _WS_CLOSED:
|
||||||
|
pass
|
||||||
|
|
||||||
async def write(self, data: bytes) -> None:
|
async def write(self, data: bytes) -> None:
|
||||||
"""Send raw bytes to the PTY stdin.
|
"""Send raw bytes to the PTY stdin.
|
||||||
|
|
||||||
@ -273,7 +302,7 @@ class AsyncPtySession:
|
|||||||
raise StopAsyncIteration
|
raise StopAsyncIteration
|
||||||
try:
|
try:
|
||||||
raw = await self._ws.receive_text()
|
raw = await self._ws.receive_text()
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
raise StopAsyncIteration
|
raise StopAsyncIteration
|
||||||
event = _parse_pty_event(json.loads(raw))
|
event = _parse_pty_event(json.loads(raw))
|
||||||
if event.type == PtyEventType.started:
|
if event.type == PtyEventType.started:
|
||||||
@ -281,6 +310,8 @@ class AsyncPtySession:
|
|||||||
self._tag = event.tag
|
self._tag = event.tag
|
||||||
if event.pid is not None:
|
if event.pid is not None:
|
||||||
self._pid = event.pid
|
self._pid = event.pid
|
||||||
|
if event.type == PtyEventType.ping:
|
||||||
|
await self._send_pong()
|
||||||
if event.type == PtyEventType.exit:
|
if event.type == PtyEventType.exit:
|
||||||
self._done = True
|
self._done = True
|
||||||
return event
|
return event
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
import respx
|
import respx
|
||||||
|
|
||||||
from wrenn.capsule import Capsule, _build_proxy_url
|
from wrenn.capsule import Capsule, _build_http_proxy_url, _build_proxy_url
|
||||||
from wrenn.code_interpreter.models import Execution, ExecutionError, Logs, Result
|
from wrenn.code_runner.models import Execution, ExecutionError, Logs, Result
|
||||||
|
|
||||||
BASE = "https://app.wrenn.dev/api"
|
BASE = "https://app.wrenn.dev/api"
|
||||||
|
API_KEY = "wrn_test1234567890abcdef12345678"
|
||||||
|
|
||||||
|
|
||||||
class TestBuildProxyUrl:
|
class TestBuildProxyUrl:
|
||||||
@ -26,13 +29,44 @@ class TestBuildProxyUrl:
|
|||||||
assert url == "ws://5000-sb-2.192.168.1.1"
|
assert url == "ws://5000-sb-2.192.168.1.1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildHttpProxyUrl:
|
||||||
|
"""``get_url`` returns an HTTP(S) URL; ``/api`` path on the base URL is
|
||||||
|
discarded — only the host is used to build the proxy subdomain."""
|
||||||
|
|
||||||
|
def test_https_production_strips_api_path(self):
|
||||||
|
url = _build_http_proxy_url("https://app.wrenn.dev/api", "cl-abc", 8080)
|
||||||
|
assert url == "https://8080-cl-abc.app.wrenn.dev"
|
||||||
|
|
||||||
|
def test_http_localhost_preserves_port(self):
|
||||||
|
url = _build_http_proxy_url("http://localhost:8080/api", "cl-abc", 3000)
|
||||||
|
assert url == "http://3000-cl-abc.localhost:8080"
|
||||||
|
|
||||||
|
def test_https_custom_port(self):
|
||||||
|
url = _build_http_proxy_url("https://api.example.com:9443", "sb-1", 80)
|
||||||
|
assert url == "https://80-sb-1.api.example.com:9443"
|
||||||
|
|
||||||
|
def test_proxy_domain_override_http(self):
|
||||||
|
url = _build_http_proxy_url(
|
||||||
|
"https://app.wrenn.dev/api", "cl-abc", 8080, "wrenn.dev"
|
||||||
|
)
|
||||||
|
assert url == "https://8080-cl-abc.wrenn.dev"
|
||||||
|
|
||||||
|
def test_proxy_domain_override_ws(self):
|
||||||
|
url = _build_proxy_url("https://app.wrenn.dev/api", "cl-abc", 8888, "wrenn.dev")
|
||||||
|
assert url == "wss://8888-cl-abc.wrenn.dev"
|
||||||
|
|
||||||
|
|
||||||
class TestCapsuleCreate:
|
class TestCapsuleCreate:
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_capsule_constructor_creates(self):
|
def test_capsule_constructor_creates(self):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "cl-1", "status": "pending", "template": "minimal"}
|
202, json={"id": "cl-1", "status": "starting", "template": "minimal"}
|
||||||
|
)
|
||||||
|
cap = Capsule(
|
||||||
|
template="minimal",
|
||||||
|
api_key="wrn_test1234567890abcdef12345678",
|
||||||
|
base_url=BASE,
|
||||||
)
|
)
|
||||||
cap = Capsule(template="minimal", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
|
||||||
assert cap.capsule_id == "cl-1"
|
assert cap.capsule_id == "cl-1"
|
||||||
assert hasattr(cap, "commands")
|
assert hasattr(cap, "commands")
|
||||||
assert hasattr(cap, "files")
|
assert hasattr(cap, "files")
|
||||||
@ -40,7 +74,7 @@ class TestCapsuleCreate:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
def test_capsule_create_classmethod(self):
|
def test_capsule_create_classmethod(self):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "cl-2", "status": "pending"}
|
202, json={"id": "cl-2", "status": "starting"}
|
||||||
)
|
)
|
||||||
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||||
assert cap.capsule_id == "cl-2"
|
assert cap.capsule_id == "cl-2"
|
||||||
@ -48,9 +82,9 @@ class TestCapsuleCreate:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
def test_capsule_context_manager_kills(self):
|
def test_capsule_context_manager_kills(self):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "cl-1", "status": "pending"}
|
202, json={"id": "cl-1", "status": "starting"}
|
||||||
)
|
)
|
||||||
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
|
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
|
||||||
with Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as cap:
|
with Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as cap:
|
||||||
assert cap.capsule_id == "cl-1"
|
assert cap.capsule_id == "cl-1"
|
||||||
assert kill_route.called
|
assert kill_route.called
|
||||||
@ -59,7 +93,7 @@ class TestCapsuleCreate:
|
|||||||
def test_capsule_env_var(self, monkeypatch):
|
def test_capsule_env_var(self, monkeypatch):
|
||||||
monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env_key")
|
monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env_key")
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "cl-3", "status": "pending"}
|
202, json={"id": "cl-3", "status": "starting"}
|
||||||
)
|
)
|
||||||
cap = Capsule(base_url=BASE)
|
cap = Capsule(base_url=BASE)
|
||||||
assert cap.capsule_id == "cl-3"
|
assert cap.capsule_id == "cl-3"
|
||||||
@ -68,17 +102,21 @@ class TestCapsuleCreate:
|
|||||||
class TestCapsuleStaticMethods:
|
class TestCapsuleStaticMethods:
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_static_destroy(self):
|
def test_static_destroy(self):
|
||||||
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
|
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
|
||||||
Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
Capsule._static_destroy(
|
||||||
|
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||||
|
)
|
||||||
assert route.called
|
assert route.called
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_static_pause(self):
|
def test_static_pause(self):
|
||||||
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
|
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
|
||||||
200, json={"id": "cl-1", "status": "paused"}
|
202, json={"id": "cl-1", "status": "pausing"}
|
||||||
)
|
)
|
||||||
info = Capsule._static_pause("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
info = Capsule._static_pause(
|
||||||
assert info.status.value == "paused"
|
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||||
|
)
|
||||||
|
assert info.status.value == "pausing"
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_static_list(self):
|
def test_static_list(self):
|
||||||
@ -106,18 +144,24 @@ class TestCapsuleConnect:
|
|||||||
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
||||||
200, json={"id": "cl-1", "status": "running"}
|
200, json={"id": "cl-1", "status": "running"}
|
||||||
)
|
)
|
||||||
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
cap = Capsule.connect(
|
||||||
|
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||||
|
)
|
||||||
assert cap.capsule_id == "cl-1"
|
assert cap.capsule_id == "cl-1"
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_connect_paused_resumes(self):
|
def test_connect_paused_resumes(self):
|
||||||
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
get_route = respx.get(f"{BASE}/v1/capsules/cl-1")
|
||||||
200, json={"id": "cl-1", "status": "paused"}
|
get_route.side_effect = [
|
||||||
)
|
httpx.Response(200, json={"id": "cl-1", "status": "paused"}),
|
||||||
|
httpx.Response(200, json={"id": "cl-1", "status": "running"}),
|
||||||
|
]
|
||||||
respx.post(f"{BASE}/v1/capsules/cl-1/resume").respond(
|
respx.post(f"{BASE}/v1/capsules/cl-1/resume").respond(
|
||||||
200, json={"id": "cl-1", "status": "running"}
|
202, json={"id": "cl-1", "status": "resuming"}
|
||||||
|
)
|
||||||
|
cap = Capsule.connect(
|
||||||
|
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||||
)
|
)
|
||||||
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
|
||||||
assert cap.capsule_id == "cl-1"
|
assert cap.capsule_id == "cl-1"
|
||||||
|
|
||||||
|
|
||||||
@ -137,10 +181,11 @@ class TestExecutionModels:
|
|||||||
assert r.png == "base64data"
|
assert r.png == "base64data"
|
||||||
assert r.is_main_result is True
|
assert r.is_main_result is True
|
||||||
|
|
||||||
def test_result_from_bundle_strips_quotes(self):
|
def test_result_from_bundle_preserves_text_plain(self):
|
||||||
|
# ``text/plain`` is the Jupyter repr — preserved verbatim now.
|
||||||
bundle = {"text/plain": "'hello'"}
|
bundle = {"text/plain": "'hello'"}
|
||||||
r = Result.from_bundle(bundle)
|
r = Result.from_bundle(bundle)
|
||||||
assert r.text == "hello"
|
assert r.text == "'hello'"
|
||||||
|
|
||||||
def test_result_from_bundle_extra_mimes(self):
|
def test_result_from_bundle_extra_mimes(self):
|
||||||
bundle = {"text/plain": "x", "application/vnd.custom": "data"}
|
bundle = {"text/plain": "x", "application/vnd.custom": "data"}
|
||||||
@ -178,6 +223,189 @@ class TestExecutionModels:
|
|||||||
assert "".join(logs.stderr) == "warn\n"
|
assert "".join(logs.stderr) == "warn\n"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetUrlPublic:
|
||||||
|
"""``Capsule.get_url`` returns the HTTP proxy URL."""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_sync_get_url_default_base(self):
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
202, json={"id": "cl-99", "status": "starting"}
|
||||||
|
)
|
||||||
|
cap = Capsule(api_key=API_KEY, base_url=BASE)
|
||||||
|
assert cap.get_url(8080) == "https://8080-cl-99.wrenn.dev"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_sync_get_url_localhost(self):
|
||||||
|
local_base = "http://localhost:8080/api"
|
||||||
|
respx.post(f"{local_base}/v1/capsules").respond(
|
||||||
|
202, json={"id": "cl-42", "status": "starting"}
|
||||||
|
)
|
||||||
|
cap = Capsule(api_key=API_KEY, base_url=local_base)
|
||||||
|
assert cap.get_url(3000) == "http://3000-cl-42.localhost:8080"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_get_url(self):
|
||||||
|
from wrenn.async_capsule import AsyncCapsule
|
||||||
|
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
202, json={"id": "cl-async", "status": "starting"}
|
||||||
|
)
|
||||||
|
cap = await AsyncCapsule.create(api_key=API_KEY, base_url=BASE)
|
||||||
|
assert cap.get_url(5000) == "https://5000-cl-async.wrenn.dev"
|
||||||
|
await cap._client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPtyConnect:
|
||||||
|
"""``pty_connect`` reconnects to an existing PTY session by tag."""
|
||||||
|
|
||||||
|
def _capsule(self):
|
||||||
|
with respx.mock:
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
202, json={"id": "cl-1", "status": "starting"}
|
||||||
|
)
|
||||||
|
return Capsule(api_key=API_KEY, base_url=BASE)
|
||||||
|
|
||||||
|
def test_sync_pty_connect_sends_connect_frame(self):
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
cap = self._capsule()
|
||||||
|
ws = MagicMock()
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.__enter__.return_value = ws
|
||||||
|
ctx.__exit__.return_value = False
|
||||||
|
|
||||||
|
with patch("wrenn.capsule.httpx_ws.connect_ws", return_value=ctx):
|
||||||
|
with cap.pty_connect("tag-xyz") as session:
|
||||||
|
assert session is not None
|
||||||
|
# First send_text call must be a ``connect`` frame with the tag.
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
sent = ws.send_text.call_args_list[0].args[0]
|
||||||
|
payload = _json.loads(sent)
|
||||||
|
assert payload == {"type": "connect", "tag": "tag-xyz"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_pty_connect_sends_connect_frame(self):
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from wrenn.async_capsule import AsyncCapsule
|
||||||
|
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
202, json={"id": "cl-1", "status": "starting"}
|
||||||
|
)
|
||||||
|
cap = await AsyncCapsule.create(api_key=API_KEY, base_url=BASE)
|
||||||
|
ws = MagicMock()
|
||||||
|
ws.send_text = AsyncMock()
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.__aenter__ = AsyncMock(return_value=ws)
|
||||||
|
ctx.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("wrenn.async_capsule.httpx_ws.aconnect_ws", return_value=ctx):
|
||||||
|
async with cap.pty_connect("tag-async") as session:
|
||||||
|
assert session is not None
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
sent = ws.send_text.call_args_list[0].args[0]
|
||||||
|
payload = _json.loads(sent)
|
||||||
|
assert payload == {"type": "connect", "tag": "tag-async"}
|
||||||
|
await cap._client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateSnapshot:
|
||||||
|
@respx.mock
|
||||||
|
def test_sync_create_snapshot_posts_capsule_id(self):
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
202, json={"id": "cl-1", "status": "starting"}
|
||||||
|
)
|
||||||
|
snap_route = respx.post(f"{BASE}/v1/snapshots").respond(
|
||||||
|
201,
|
||||||
|
json={"name": "my-snap"},
|
||||||
|
)
|
||||||
|
cap = Capsule(api_key=API_KEY, base_url=BASE)
|
||||||
|
tpl = cap.create_snapshot(name="my-snap", overwrite=True)
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
req = snap_route.calls[0].request
|
||||||
|
body = _json.loads(req.content)
|
||||||
|
assert body["sandbox_id"] == "cl-1"
|
||||||
|
assert body["name"] == "my-snap"
|
||||||
|
assert req.url.params["overwrite"] == "true"
|
||||||
|
assert tpl.name == "my-snap"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_create_snapshot(self):
|
||||||
|
from wrenn.async_capsule import AsyncCapsule
|
||||||
|
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
202, json={"id": "cl-1", "status": "starting"}
|
||||||
|
)
|
||||||
|
respx.post(f"{BASE}/v1/snapshots").respond(
|
||||||
|
201,
|
||||||
|
json={"name": "auto-named"},
|
||||||
|
)
|
||||||
|
cap = await AsyncCapsule.create(api_key=API_KEY, base_url=BASE)
|
||||||
|
tpl = await cap.create_snapshot()
|
||||||
|
assert tpl.name == "auto-named"
|
||||||
|
await cap._client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUploadStreamChunked:
|
||||||
|
"""``upload_stream`` must declare ``Transfer-Encoding: chunked`` and
|
||||||
|
deliver the multipart body without buffering."""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_sync_upload_stream_chunked(self):
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
202, json={"id": "cl-1", "status": "starting"}
|
||||||
|
)
|
||||||
|
route = respx.post(f"{BASE}/v1/capsules/cl-1/files/stream/write").respond(
|
||||||
|
200, json={}
|
||||||
|
)
|
||||||
|
cap = Capsule(api_key=API_KEY, base_url=BASE)
|
||||||
|
|
||||||
|
def chunks():
|
||||||
|
yield b"hello "
|
||||||
|
yield b"world\n"
|
||||||
|
|
||||||
|
cap.files.upload_stream("/tmp/out.txt", chunks())
|
||||||
|
req = route.calls[0].request
|
||||||
|
assert req.headers["transfer-encoding"] == "chunked"
|
||||||
|
ct = req.headers["content-type"]
|
||||||
|
assert ct.startswith("multipart/form-data; boundary=")
|
||||||
|
body = bytes(req.content)
|
||||||
|
assert b'name="path"' in body
|
||||||
|
assert b"/tmp/out.txt" in body
|
||||||
|
assert b'name="file"' in body
|
||||||
|
assert b"hello world\n" in body
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_upload_stream_chunked(self):
|
||||||
|
from wrenn.async_capsule import AsyncCapsule
|
||||||
|
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
202, json={"id": "cl-1", "status": "starting"}
|
||||||
|
)
|
||||||
|
route = respx.post(f"{BASE}/v1/capsules/cl-1/files/stream/write").respond(
|
||||||
|
200, json={}
|
||||||
|
)
|
||||||
|
cap = await AsyncCapsule.create(api_key=API_KEY, base_url=BASE)
|
||||||
|
|
||||||
|
async def chunks():
|
||||||
|
yield b"abc"
|
||||||
|
yield b"def"
|
||||||
|
|
||||||
|
await cap.files.upload_stream("/tmp/out.bin", chunks())
|
||||||
|
req = route.calls[0].request
|
||||||
|
assert req.headers["transfer-encoding"] == "chunked"
|
||||||
|
body = bytes(req.content)
|
||||||
|
assert b"abcdef" in body
|
||||||
|
await cap._client.aclose()
|
||||||
|
|
||||||
|
|
||||||
class TestDeprecationWarnings:
|
class TestDeprecationWarnings:
|
||||||
def test_import_sandbox_from_wrenn_warns(self):
|
def test_import_sandbox_from_wrenn_warns(self):
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@ -36,10 +36,10 @@ class TestCapsules:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
def test_create(self, client):
|
def test_create(self, client):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201,
|
202,
|
||||||
json={
|
json={
|
||||||
"id": "sb-1",
|
"id": "sb-1",
|
||||||
"status": "pending",
|
"status": "starting",
|
||||||
"template": "base-python",
|
"template": "base-python",
|
||||||
"vcpus": 2,
|
"vcpus": 2,
|
||||||
"memory_mb": 1024,
|
"memory_mb": 1024,
|
||||||
@ -48,12 +48,12 @@ class TestCapsules:
|
|||||||
resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024)
|
resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024)
|
||||||
assert isinstance(resp, Capsule)
|
assert isinstance(resp, Capsule)
|
||||||
assert resp.id == "sb-1"
|
assert resp.id == "sb-1"
|
||||||
assert resp.status == Status.pending
|
assert resp.status == Status.starting
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_create_defaults(self, client):
|
def test_create_defaults(self, client):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "sb-2", "status": "pending"}
|
202, json={"id": "sb-2", "status": "starting"}
|
||||||
)
|
)
|
||||||
resp = client.capsules.create()
|
resp = client.capsules.create()
|
||||||
assert resp.id == "sb-2"
|
assert resp.id == "sb-2"
|
||||||
@ -77,25 +77,25 @@ class TestCapsules:
|
|||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_destroy(self, client):
|
def test_destroy(self, client):
|
||||||
route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(204)
|
route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(202)
|
||||||
client.capsules.destroy("sb-1")
|
client.capsules.destroy("sb-1")
|
||||||
assert route.called
|
assert route.called
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_pause(self, client):
|
def test_pause(self, client):
|
||||||
respx.post(f"{BASE}/v1/capsules/sb-1/pause").respond(
|
respx.post(f"{BASE}/v1/capsules/sb-1/pause").respond(
|
||||||
200, json={"id": "sb-1", "status": "paused"}
|
202, json={"id": "sb-1", "status": "pausing"}
|
||||||
)
|
)
|
||||||
resp = client.capsules.pause("sb-1")
|
resp = client.capsules.pause("sb-1")
|
||||||
assert resp.status == Status.paused
|
assert resp.status == Status.pausing
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_resume(self, client):
|
def test_resume(self, client):
|
||||||
respx.post(f"{BASE}/v1/capsules/sb-1/resume").respond(
|
respx.post(f"{BASE}/v1/capsules/sb-1/resume").respond(
|
||||||
200, json={"id": "sb-1", "status": "running"}
|
202, json={"id": "sb-1", "status": "resuming"}
|
||||||
)
|
)
|
||||||
resp = client.capsules.resume("sb-1")
|
resp = client.capsules.resume("sb-1")
|
||||||
assert resp.status == Status.running
|
assert resp.status == Status.resuming
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_ping(self, client):
|
def test_ping(self, client):
|
||||||
@ -238,7 +238,7 @@ class TestAsyncClient:
|
|||||||
async def test_async_capsules_create(self, async_client):
|
async def test_async_capsules_create(self, async_client):
|
||||||
async with async_client:
|
async with async_client:
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "sb-1", "status": "pending"}
|
202, json={"id": "sb-1", "status": "starting"}
|
||||||
)
|
)
|
||||||
resp = await async_client.capsules.create(template="base-python")
|
resp = await async_client.capsules.create(template="base-python")
|
||||||
assert resp.id == "sb-1"
|
assert resp.id == "sb-1"
|
||||||
@ -261,3 +261,39 @@ class TestAsyncClient:
|
|||||||
)
|
)
|
||||||
with pytest.raises(WrennNotFoundError):
|
with pytest.raises(WrennNotFoundError):
|
||||||
await async_client.capsules.get("nope")
|
await async_client.capsules.get("nope")
|
||||||
|
|
||||||
|
|
||||||
|
class TestClientResolution:
|
||||||
|
def test_default_base_url_strips_app_subdomain(self):
|
||||||
|
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
||||||
|
assert c._proxy_domain == "wrenn.dev"
|
||||||
|
|
||||||
|
def test_custom_base_url_preserves_host(self):
|
||||||
|
with WrennClient(
|
||||||
|
api_key="wrn_test1234567890abcdef12345678",
|
||||||
|
base_url="http://localhost:8080/api",
|
||||||
|
) as c:
|
||||||
|
assert c._proxy_domain == "localhost:8080"
|
||||||
|
|
||||||
|
def test_explicit_proxy_domain_wins(self):
|
||||||
|
with WrennClient(
|
||||||
|
api_key="wrn_test1234567890abcdef12345678",
|
||||||
|
base_url="https://app.wrenn.dev/api",
|
||||||
|
proxy_domain="custom.example.com",
|
||||||
|
) as c:
|
||||||
|
assert c._proxy_domain == "custom.example.com"
|
||||||
|
|
||||||
|
def test_env_proxy_domain(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("WRENN_PROXY_DOMAIN", "env.example.com")
|
||||||
|
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
||||||
|
assert c._proxy_domain == "env.example.com"
|
||||||
|
|
||||||
|
def test_default_timeout(self):
|
||||||
|
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
||||||
|
t = c._http.timeout
|
||||||
|
assert t.connect == 10.0
|
||||||
|
assert t.read == 30.0
|
||||||
|
|
||||||
|
def test_timeout_float_override(self):
|
||||||
|
with WrennClient(api_key="wrn_test1234567890abcdef12345678", timeout=5.0) as c:
|
||||||
|
assert c._http.timeout.connect == 5.0
|
||||||
|
|||||||
521
tests/test_code_runner_e2e.py
Normal file
521
tests/test_code_runner_e2e.py
Normal file
@ -0,0 +1,521 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import warnings
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wrenn.code_runner import (
|
||||||
|
AsyncCapsule,
|
||||||
|
Capsule,
|
||||||
|
Execution,
|
||||||
|
Result,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── Sync e2e ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestCodeRunnerSync:
|
||||||
|
"""Shared capsule — kernel state persists across 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_uses_code_runner_beta_template(self):
|
||||||
|
assert self.capsule.info is not None
|
||||||
|
assert self.capsule.info.template == "code-runner-beta"
|
||||||
|
|
||||||
|
def test_default_kernel_name_is_wrenn(self):
|
||||||
|
assert self.capsule._kernel_name == "wrenn"
|
||||||
|
|
||||||
|
def test_simple_expression(self):
|
||||||
|
ex = self.capsule.run_code("1 + 1")
|
||||||
|
assert isinstance(ex, Execution)
|
||||||
|
assert ex.error is None
|
||||||
|
assert ex.text == "2"
|
||||||
|
assert ex.execution_count is not None
|
||||||
|
assert ex.execution_count >= 1
|
||||||
|
|
||||||
|
def test_print_captures_stdout(self):
|
||||||
|
ex = self.capsule.run_code("print('hello world')")
|
||||||
|
assert ex.error is None
|
||||||
|
joined = "".join(ex.logs.stdout)
|
||||||
|
assert "hello world" in joined
|
||||||
|
|
||||||
|
def test_stderr_captured(self):
|
||||||
|
ex = self.capsule.run_code("import sys; sys.stderr.write('an error\\n')")
|
||||||
|
assert ex.error is None
|
||||||
|
joined = "".join(ex.logs.stderr)
|
||||||
|
assert "an error" in joined
|
||||||
|
|
||||||
|
def test_kernel_state_persists_across_calls(self):
|
||||||
|
self.capsule.run_code("persistent_value = 12345")
|
||||||
|
ex = self.capsule.run_code("persistent_value")
|
||||||
|
assert ex.text == "12345"
|
||||||
|
|
||||||
|
def test_import_persists(self):
|
||||||
|
self.capsule.run_code("import math")
|
||||||
|
ex = self.capsule.run_code("round(math.pi, 4)")
|
||||||
|
assert ex.text == "3.1416"
|
||||||
|
|
||||||
|
def test_function_definition_persists(self):
|
||||||
|
self.capsule.run_code(
|
||||||
|
"def fib(n):\n"
|
||||||
|
" a, b = 0, 1\n"
|
||||||
|
" for _ in range(n):\n"
|
||||||
|
" a, b = b, a + b\n"
|
||||||
|
" return a\n"
|
||||||
|
)
|
||||||
|
ex = self.capsule.run_code("fib(10)")
|
||||||
|
assert ex.text == "55"
|
||||||
|
|
||||||
|
def test_class_definition_persists(self):
|
||||||
|
self.capsule.run_code(
|
||||||
|
"class Counter:\n"
|
||||||
|
" def __init__(self): self.n = 0\n"
|
||||||
|
" def inc(self): self.n += 1; return self.n\n"
|
||||||
|
"c = Counter()\n"
|
||||||
|
)
|
||||||
|
ex = self.capsule.run_code("c.inc(); c.inc(); c.inc(); c.n")
|
||||||
|
assert ex.text == "3"
|
||||||
|
|
||||||
|
def test_exception_captured(self):
|
||||||
|
ex = self.capsule.run_code("raise ValueError('boom')")
|
||||||
|
assert ex.error is not None
|
||||||
|
assert ex.error.name == "ValueError"
|
||||||
|
assert "boom" in ex.error.value
|
||||||
|
assert "ValueError" in ex.error.traceback
|
||||||
|
|
||||||
|
def test_name_error(self):
|
||||||
|
ex = self.capsule.run_code("undefined_symbol_xyz")
|
||||||
|
assert ex.error is not None
|
||||||
|
assert ex.error.name == "NameError"
|
||||||
|
|
||||||
|
def test_syntax_error(self):
|
||||||
|
ex = self.capsule.run_code("def )(\n")
|
||||||
|
assert ex.error is not None
|
||||||
|
assert "SyntaxError" in ex.error.name
|
||||||
|
|
||||||
|
def test_callbacks_fire(self):
|
||||||
|
stdout_chunks: list[str] = []
|
||||||
|
stderr_chunks: list[str] = []
|
||||||
|
results: list[Result] = []
|
||||||
|
errors = []
|
||||||
|
self.capsule.run_code(
|
||||||
|
"import sys\nprint('on stdout')\nsys.stderr.write('on stderr\\n')\n42\n",
|
||||||
|
on_stdout=stdout_chunks.append,
|
||||||
|
on_stderr=stderr_chunks.append,
|
||||||
|
on_result=results.append,
|
||||||
|
on_error=errors.append,
|
||||||
|
)
|
||||||
|
assert any("on stdout" in c for c in stdout_chunks)
|
||||||
|
assert any("on stderr" in c for c in stderr_chunks)
|
||||||
|
assert any(r.text == "42" for r in results)
|
||||||
|
assert errors == []
|
||||||
|
|
||||||
|
def test_multi_line_output(self):
|
||||||
|
ex = self.capsule.run_code("for i in range(3):\n print(i)\n")
|
||||||
|
joined = "".join(ex.logs.stdout)
|
||||||
|
assert "0" in joined and "1" in joined and "2" in joined
|
||||||
|
|
||||||
|
def test_no_main_result_when_statement_only(self):
|
||||||
|
ex = self.capsule.run_code("x = 5")
|
||||||
|
assert ex.text is None
|
||||||
|
assert ex.error is None
|
||||||
|
|
||||||
|
def test_html_repr_result(self):
|
||||||
|
ex = self.capsule.run_code(
|
||||||
|
"from IPython.display import HTML\nHTML('<b>bold</b>')"
|
||||||
|
)
|
||||||
|
assert ex.error is None
|
||||||
|
main = [r for r in ex.results if r.is_main_result]
|
||||||
|
assert main, "expected execute_result"
|
||||||
|
assert main[0].html is not None
|
||||||
|
assert "<b>bold</b>" in main[0].html
|
||||||
|
|
||||||
|
def test_display_data_separate_from_execute_result(self):
|
||||||
|
ex = self.capsule.run_code(
|
||||||
|
"from IPython.display import display, HTML\n"
|
||||||
|
"display(HTML('<i>shown</i>'))\n"
|
||||||
|
"'final'\n"
|
||||||
|
)
|
||||||
|
assert ex.error is None
|
||||||
|
mains = [r for r in ex.results if r.is_main_result]
|
||||||
|
displays = [r for r in ex.results if not r.is_main_result]
|
||||||
|
assert len(mains) == 1
|
||||||
|
assert mains[0].text == "'final'"
|
||||||
|
assert len(displays) >= 1
|
||||||
|
assert any(r.html and "shown" in r.html for r in displays)
|
||||||
|
|
||||||
|
def test_matplotlib_png(self):
|
||||||
|
ex = self.capsule.run_code(
|
||||||
|
"%matplotlib inline\n"
|
||||||
|
"import matplotlib.pyplot as plt\n"
|
||||||
|
"plt.figure()\n"
|
||||||
|
"plt.plot([1,2,3],[4,1,5])\n"
|
||||||
|
"plt.show()\n"
|
||||||
|
)
|
||||||
|
if ex.error is not None and ex.error.name == "ModuleNotFoundError":
|
||||||
|
pytest.skip("matplotlib not in template")
|
||||||
|
assert ex.error is None
|
||||||
|
pngs = [r for r in ex.results if r.png is not None]
|
||||||
|
assert pngs, "expected at least one PNG result from plt.show()"
|
||||||
|
|
||||||
|
def test_pandas_repr(self):
|
||||||
|
ex = self.capsule.run_code(
|
||||||
|
"import pandas as pd\npd.DataFrame({'a':[1,2],'b':[3,4]})\n"
|
||||||
|
)
|
||||||
|
if ex.error is not None and ex.error.name == "ModuleNotFoundError":
|
||||||
|
pytest.skip("pandas not in template")
|
||||||
|
assert ex.error is None
|
||||||
|
main = [r for r in ex.results if r.is_main_result]
|
||||||
|
assert main
|
||||||
|
assert main[0].html is not None or main[0].text is not None
|
||||||
|
|
||||||
|
def test_filesystem_round_trip(self):
|
||||||
|
self.capsule.run_code(
|
||||||
|
"with open('/tmp/from_kernel.txt','w') as f: f.write('written-by-kernel')"
|
||||||
|
)
|
||||||
|
content = self.capsule.files.read("/tmp/from_kernel.txt")
|
||||||
|
assert content == "written-by-kernel"
|
||||||
|
|
||||||
|
def test_text_preserves_string_repr(self):
|
||||||
|
"""Strings keep their surrounding quotes — the ``text/plain`` MIME
|
||||||
|
is the Jupyter repr, which is what disambiguates ``'2'`` from
|
||||||
|
``2``."""
|
||||||
|
ex = self.capsule.run_code("'hello'")
|
||||||
|
assert ex.text == "'hello'"
|
||||||
|
ex = self.capsule.run_code('"with\\"inside"')
|
||||||
|
assert ex.text is not None
|
||||||
|
assert ex.text.startswith("'") or ex.text.startswith('"')
|
||||||
|
ex = self.capsule.run_code("42")
|
||||||
|
assert ex.text == "42"
|
||||||
|
ex = self.capsule.run_code("[1, 2, 3]")
|
||||||
|
assert ex.text == "[1, 2, 3]"
|
||||||
|
ex = self.capsule.run_code("{'k': 'v'}")
|
||||||
|
assert ex.text == "{'k': 'v'}"
|
||||||
|
|
||||||
|
def test_kernel_id_cached(self):
|
||||||
|
first = self.capsule._kernel_id
|
||||||
|
self.capsule.run_code("1")
|
||||||
|
assert self.capsule._kernel_id == first
|
||||||
|
|
||||||
|
def test_complex_workflow(self):
|
||||||
|
ex = self.capsule.run_code(
|
||||||
|
"import json\n"
|
||||||
|
"data = [{'n': i, 'sq': i*i} for i in range(5)]\n"
|
||||||
|
"print(json.dumps(data))\n"
|
||||||
|
"sum(d['sq'] for d in data)\n"
|
||||||
|
)
|
||||||
|
assert ex.error is None
|
||||||
|
assert ex.text == "30"
|
||||||
|
assert any('"sq": 16' in c for c in ex.logs.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCodeRunnerMimeTypes:
|
||||||
|
"""Cover every non-text MIME field on ``Result`` using the libs
|
||||||
|
baked into the ``code-runner-beta`` template
|
||||||
|
(numpy, pandas, matplotlib, seaborn, requests)."""
|
||||||
|
|
||||||
|
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 _run(self, code: str) -> Execution:
|
||||||
|
ex = self.capsule.run_code(code, timeout=60)
|
||||||
|
assert ex.error is None, f"unexpected error: {ex.error}"
|
||||||
|
return ex
|
||||||
|
|
||||||
|
# ── html ──────────────────────────────────────────────────────
|
||||||
|
def test_html_via_ipython_display(self):
|
||||||
|
ex = self._run(
|
||||||
|
"from IPython.display import HTML\nHTML('<table><tr><td>x</td></tr></table>')"
|
||||||
|
)
|
||||||
|
main = next(r for r in ex.results if r.is_main_result)
|
||||||
|
assert main.html is not None
|
||||||
|
assert "<table>" in main.html
|
||||||
|
assert "html" in main.formats()
|
||||||
|
|
||||||
|
def test_html_via_pandas_dataframe(self):
|
||||||
|
ex = self._run(
|
||||||
|
"import pandas as pd\n"
|
||||||
|
"pd.DataFrame({'a': [1, 2, 3], 'b': ['x', 'y', 'z']})\n"
|
||||||
|
)
|
||||||
|
main = next(r for r in ex.results if r.is_main_result)
|
||||||
|
assert main.html is not None
|
||||||
|
# pandas emits a styled <table>
|
||||||
|
assert "<table" in main.html
|
||||||
|
assert "dataframe" in main.html.lower() or "<tr" in main.html
|
||||||
|
# text/plain still present alongside html
|
||||||
|
assert main.text is not None
|
||||||
|
|
||||||
|
# ── markdown ──────────────────────────────────────────────────
|
||||||
|
def test_markdown(self):
|
||||||
|
ex = self._run(
|
||||||
|
"from IPython.display import Markdown\nMarkdown('# heading\\n* a\\n* b')"
|
||||||
|
)
|
||||||
|
main = next(r for r in ex.results if r.is_main_result)
|
||||||
|
assert main.markdown is not None
|
||||||
|
assert "# heading" in main.markdown
|
||||||
|
assert "markdown" in main.formats()
|
||||||
|
|
||||||
|
# ── json ──────────────────────────────────────────────────────
|
||||||
|
def test_json_bundle(self):
|
||||||
|
ex = self._run(
|
||||||
|
"from IPython.display import JSON\nJSON({'a': 1, 'nested': {'b': [1, 2]}})"
|
||||||
|
)
|
||||||
|
main = next(r for r in ex.results if r.is_main_result)
|
||||||
|
# IPython.display.JSON emits application/json
|
||||||
|
assert main.json is not None
|
||||||
|
assert main.json == {"a": 1, "nested": {"b": [1, 2]}}
|
||||||
|
assert "json" in main.formats()
|
||||||
|
|
||||||
|
# ── latex ─────────────────────────────────────────────────────
|
||||||
|
def test_latex(self):
|
||||||
|
ex = self._run("from IPython.display import Latex\nLatex(r'$E = mc^2$')")
|
||||||
|
main = next(r for r in ex.results if r.is_main_result)
|
||||||
|
assert main.latex is not None
|
||||||
|
assert "mc^2" in main.latex
|
||||||
|
|
||||||
|
# ── svg ───────────────────────────────────────────────────────
|
||||||
|
def test_svg(self):
|
||||||
|
svg_payload = (
|
||||||
|
'<svg xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"10\\" height=\\"10\\">'
|
||||||
|
'<rect width=\\"10\\" height=\\"10\\" fill=\\"red\\"/></svg>'
|
||||||
|
)
|
||||||
|
ex = self._run(f"from IPython.display import SVG\nSVG(data='{svg_payload}')")
|
||||||
|
main = next(r for r in ex.results if r.is_main_result)
|
||||||
|
assert main.svg is not None
|
||||||
|
assert "<svg" in main.svg
|
||||||
|
assert "<rect" in main.svg
|
||||||
|
|
||||||
|
# ── javascript ────────────────────────────────────────────────
|
||||||
|
def test_javascript(self):
|
||||||
|
ex = self._run(
|
||||||
|
"from IPython.display import Javascript\nJavascript('console.log(\"hi\")')"
|
||||||
|
)
|
||||||
|
main = next(r for r in ex.results if r.is_main_result)
|
||||||
|
# Some IPython versions only emit text/plain for Javascript;
|
||||||
|
# accept either javascript or extra/application/javascript.
|
||||||
|
js = main.javascript or (main.extra or {}).get("application/javascript")
|
||||||
|
assert js is not None, f"no js payload, got formats: {main.formats()}"
|
||||||
|
assert "console.log" in js
|
||||||
|
|
||||||
|
# ── png (matplotlib) ──────────────────────────────────────────
|
||||||
|
def test_png_from_matplotlib(self):
|
||||||
|
ex = self._run(
|
||||||
|
"%matplotlib inline\n"
|
||||||
|
"import matplotlib.pyplot as plt\n"
|
||||||
|
"import numpy as np\n"
|
||||||
|
"x = np.linspace(0, 6.28, 100)\n"
|
||||||
|
"plt.figure()\n"
|
||||||
|
"plt.plot(x, np.sin(x))\n"
|
||||||
|
"plt.title('sine')\n"
|
||||||
|
"plt.show()\n"
|
||||||
|
)
|
||||||
|
pngs = [r for r in ex.results if r.png is not None]
|
||||||
|
assert pngs, "expected PNG from plt.show()"
|
||||||
|
# Base64 PNG starts with iVBORw0KGgo (== PNG magic in base64)
|
||||||
|
assert pngs[0].png.startswith("iVBORw0KGgo")
|
||||||
|
assert "png" in pngs[0].formats()
|
||||||
|
|
||||||
|
def test_png_from_seaborn(self):
|
||||||
|
ex = self._run(
|
||||||
|
"%matplotlib inline\n"
|
||||||
|
"import matplotlib.pyplot as plt\n"
|
||||||
|
"import seaborn as sns\n"
|
||||||
|
"import pandas as pd\n"
|
||||||
|
"df = pd.DataFrame({'x': [1, 2, 3, 4], 'y': [10, 20, 15, 25]})\n"
|
||||||
|
"plt.figure()\n"
|
||||||
|
"sns.barplot(data=df, x='x', y='y')\n"
|
||||||
|
"plt.show()\n"
|
||||||
|
)
|
||||||
|
pngs = [r for r in ex.results if r.png is not None]
|
||||||
|
assert pngs, "expected PNG from seaborn plot"
|
||||||
|
assert pngs[0].png.startswith("iVBORw0KGgo")
|
||||||
|
|
||||||
|
# ── jpeg ──────────────────────────────────────────────────────
|
||||||
|
def test_jpeg_via_matplotlib(self):
|
||||||
|
ex = self._run(
|
||||||
|
"%matplotlib inline\n"
|
||||||
|
"import matplotlib.pyplot as plt\n"
|
||||||
|
"import matplotlib_inline.backend_inline as bi\n"
|
||||||
|
"bi.set_matplotlib_formats('jpeg')\n"
|
||||||
|
"plt.figure()\n"
|
||||||
|
"plt.plot([1, 2, 3])\n"
|
||||||
|
"plt.show()\n"
|
||||||
|
"bi.set_matplotlib_formats('png')\n"
|
||||||
|
)
|
||||||
|
jpegs = [r for r in ex.results if r.jpeg is not None]
|
||||||
|
if not jpegs:
|
||||||
|
pytest.skip("matplotlib_inline jpeg backend unavailable")
|
||||||
|
# JPEG magic in base64 starts with /9j/
|
||||||
|
assert jpegs[0].jpeg.startswith("/9j/")
|
||||||
|
|
||||||
|
# ── multi-format bundle ───────────────────────────────────────
|
||||||
|
def test_pandas_emits_text_and_html(self):
|
||||||
|
ex = self._run("import pandas as pd\npd.DataFrame({'n': range(3)})")
|
||||||
|
main = next(r for r in ex.results if r.is_main_result)
|
||||||
|
fmts = main.formats()
|
||||||
|
assert "text" in fmts
|
||||||
|
assert "html" in fmts
|
||||||
|
assert main.is_main_result is True
|
||||||
|
|
||||||
|
def test_matplotlib_figure_emits_png_and_text(self):
|
||||||
|
ex = self._run(
|
||||||
|
"%matplotlib inline\n"
|
||||||
|
"import matplotlib.pyplot as plt\n"
|
||||||
|
"fig, ax = plt.subplots()\n"
|
||||||
|
"ax.plot([1, 2, 3])\n"
|
||||||
|
"fig\n" # return the figure as the last expression
|
||||||
|
)
|
||||||
|
main = next(r for r in ex.results if r.is_main_result)
|
||||||
|
fmts = main.formats()
|
||||||
|
# Figure repr bundles both text and png.
|
||||||
|
assert "png" in fmts
|
||||||
|
assert "text" in fmts
|
||||||
|
|
||||||
|
# ── numpy / requests round-trips through .text ────────────────
|
||||||
|
def test_numpy_array_text_repr(self):
|
||||||
|
ex = self._run("import numpy as np\nnp.arange(5)")
|
||||||
|
assert ex.text is not None
|
||||||
|
assert "array([0, 1, 2, 3, 4])" in ex.text
|
||||||
|
|
||||||
|
def test_requests_status_code(self):
|
||||||
|
ex = self._run(
|
||||||
|
"import requests\n"
|
||||||
|
"r = requests.get('https://httpbin.org/status/204', timeout=10)\n"
|
||||||
|
"r.status_code\n"
|
||||||
|
)
|
||||||
|
if ex.error is not None:
|
||||||
|
pytest.skip(f"network unavailable: {ex.error.name}")
|
||||||
|
assert ex.text == "204"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCodeRunnerIsolation:
|
||||||
|
"""Each test gets its own capsule — verifies fresh-kernel boot."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
_ensure_env()
|
||||||
|
|
||||||
|
def test_fresh_capsule_no_state_leak(self):
|
||||||
|
c1 = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
c1.run_code("leaked = 'c1'")
|
||||||
|
c2 = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
ex = c2.run_code("leaked")
|
||||||
|
assert ex.error is not None
|
||||||
|
assert ex.error.name == "NameError"
|
||||||
|
finally:
|
||||||
|
c2.destroy()
|
||||||
|
finally:
|
||||||
|
c1.destroy()
|
||||||
|
|
||||||
|
def test_context_manager(self):
|
||||||
|
with Capsule(wait=True) as c:
|
||||||
|
ex = c.run_code("'ctx'")
|
||||||
|
assert ex.text == "'ctx'"
|
||||||
|
|
||||||
|
def test_deprecated_code_interpreter_import_still_works(self):
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore", FutureWarning)
|
||||||
|
from wrenn.code_interpreter import Capsule as LegacyCapsule
|
||||||
|
with LegacyCapsule(wait=True) as c:
|
||||||
|
ex = c.run_code("'legacy'")
|
||||||
|
assert ex.text == "'legacy'"
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── Async e2e ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestCodeRunnerAsync:
|
||||||
|
def setup_method(self):
|
||||||
|
_ensure_env()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_simple(self):
|
||||||
|
async with await AsyncCapsule.create(wait=True) as c:
|
||||||
|
ex = await c.run_code("21 * 2")
|
||||||
|
assert ex.error is None
|
||||||
|
assert ex.text == "42"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_persistence(self):
|
||||||
|
async with await AsyncCapsule.create(wait=True) as c:
|
||||||
|
await c.run_code("v = 'persisted'")
|
||||||
|
ex = await c.run_code("v")
|
||||||
|
assert ex.text == "'persisted'"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_callbacks(self):
|
||||||
|
async with await AsyncCapsule.create(wait=True) as c:
|
||||||
|
chunks: list[str] = []
|
||||||
|
await c.run_code(
|
||||||
|
"print('async out')",
|
||||||
|
on_stdout=chunks.append,
|
||||||
|
)
|
||||||
|
assert any("async out" in s for s in chunks)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_context_manager(self):
|
||||||
|
async with await AsyncCapsule.create(wait=True) as c:
|
||||||
|
ex = await c.run_code("'in-ctx'")
|
||||||
|
assert ex.text == "'in-ctx'"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_concurrent_capsules(self):
|
||||||
|
async with await AsyncCapsule.create(wait=True) as c1:
|
||||||
|
async with await AsyncCapsule.create(wait=True) as c2:
|
||||||
|
r1, r2 = await asyncio.gather(
|
||||||
|
c1.run_code("1 + 1"),
|
||||||
|
c2.run_code("10 * 10"),
|
||||||
|
)
|
||||||
|
assert r1.text == "2"
|
||||||
|
assert r2.text == "100"
|
||||||
887
tests/test_code_runner_unit.py
Normal file
887
tests/test_code_runner_unit.py
Normal file
@ -0,0 +1,887 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from wrenn.code_runner import (
|
||||||
|
AsyncCapsule,
|
||||||
|
Capsule,
|
||||||
|
Execution,
|
||||||
|
Logs,
|
||||||
|
Result,
|
||||||
|
)
|
||||||
|
from wrenn.code_runner.capsule import DEFAULT_KERNEL, DEFAULT_TEMPLATE
|
||||||
|
|
||||||
|
BASE = "https://app.wrenn.dev/api"
|
||||||
|
API_KEY = "wrn_test1234567890abcdef12345678"
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── Result / Execution models ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestResultFromBundle:
|
||||||
|
def test_unpacks_known_mime_types(self):
|
||||||
|
r = Result.from_bundle(
|
||||||
|
{
|
||||||
|
"text/plain": "42",
|
||||||
|
"text/html": "<b>42</b>",
|
||||||
|
"image/png": "iVBORw0KGgo=",
|
||||||
|
"application/json": {"x": 1},
|
||||||
|
},
|
||||||
|
is_main_result=True,
|
||||||
|
)
|
||||||
|
assert r.text == "42"
|
||||||
|
assert r.html == "<b>42</b>"
|
||||||
|
assert r.png == "iVBORw0KGgo="
|
||||||
|
assert r.json == {"x": 1}
|
||||||
|
assert r.is_main_result is True
|
||||||
|
assert r.extra is None
|
||||||
|
|
||||||
|
def test_unknown_mime_lands_in_extra(self):
|
||||||
|
r = Result.from_bundle({"application/vnd.custom+json": "{}"})
|
||||||
|
assert r.extra == {"application/vnd.custom+json": "{}"}
|
||||||
|
assert r.is_main_result is False
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw",
|
||||||
|
[
|
||||||
|
"'hello'",
|
||||||
|
'"hello"',
|
||||||
|
"hello",
|
||||||
|
"'x",
|
||||||
|
"''",
|
||||||
|
"'",
|
||||||
|
"'it\\'s'",
|
||||||
|
"{'a': 1}",
|
||||||
|
"[1, 2, 3]",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_text_plain_preserved_verbatim(self, raw):
|
||||||
|
"""``text/plain`` is the Jupyter repr — pass through unchanged.
|
||||||
|
Stripping outer quotes would lose string identity (a string
|
||||||
|
``'2'`` would become indistinguishable from the int ``2``)."""
|
||||||
|
r = Result.from_bundle({"text/plain": raw})
|
||||||
|
assert r.text == raw
|
||||||
|
|
||||||
|
def test_formats_lists_present_fields(self):
|
||||||
|
r = Result.from_bundle({"text/plain": "x", "image/svg+xml": "<svg/>"})
|
||||||
|
fmts = r.formats()
|
||||||
|
assert "text" in fmts
|
||||||
|
assert "svg" in fmts
|
||||||
|
assert "html" not in fmts
|
||||||
|
|
||||||
|
def test_formats_includes_extra(self):
|
||||||
|
r = Result.from_bundle({"application/x-foo": "bar"})
|
||||||
|
assert "application/x-foo" in r.formats()
|
||||||
|
|
||||||
|
def test_all_mime_types_map(self):
|
||||||
|
r = Result.from_bundle(
|
||||||
|
{
|
||||||
|
"text/plain": "a",
|
||||||
|
"text/html": "b",
|
||||||
|
"text/markdown": "c",
|
||||||
|
"image/svg+xml": "d",
|
||||||
|
"image/png": "e",
|
||||||
|
"image/jpeg": "f",
|
||||||
|
"application/pdf": "g",
|
||||||
|
"text/latex": "h",
|
||||||
|
"application/json": {"k": 1},
|
||||||
|
"application/javascript": "j",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for attr in (
|
||||||
|
"text",
|
||||||
|
"html",
|
||||||
|
"markdown",
|
||||||
|
"svg",
|
||||||
|
"png",
|
||||||
|
"jpeg",
|
||||||
|
"pdf",
|
||||||
|
"latex",
|
||||||
|
"json",
|
||||||
|
"javascript",
|
||||||
|
):
|
||||||
|
assert getattr(r, attr) is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecution:
|
||||||
|
def test_text_returns_main_result(self):
|
||||||
|
ex = Execution(
|
||||||
|
results=[
|
||||||
|
Result(text="display", is_main_result=False),
|
||||||
|
Result(text="main", is_main_result=True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert ex.text == "main"
|
||||||
|
|
||||||
|
def test_text_none_when_no_main(self):
|
||||||
|
ex = Execution(results=[Result(text="x", is_main_result=False)])
|
||||||
|
assert ex.text is None
|
||||||
|
|
||||||
|
def test_defaults(self):
|
||||||
|
ex = Execution()
|
||||||
|
assert ex.results == []
|
||||||
|
assert isinstance(ex.logs, Logs)
|
||||||
|
assert ex.error is None
|
||||||
|
assert ex.execution_count is None
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── deprecation alias ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeprecationAlias:
|
||||||
|
def test_code_interpreter_emits_warning_on_import(self):
|
||||||
|
# Force a fresh import to observe the warning.
|
||||||
|
sys.modules.pop("wrenn.code_interpreter", None)
|
||||||
|
# Reset the one-shot flag in case the module was previously imported.
|
||||||
|
with warnings.catch_warnings(record=True) as captured:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
ci = importlib.import_module("wrenn.code_interpreter")
|
||||||
|
ci.warnings_emitted = False # type: ignore[attr-defined]
|
||||||
|
# Re-import to trigger again
|
||||||
|
sys.modules.pop("wrenn.code_interpreter", None)
|
||||||
|
importlib.import_module("wrenn.code_interpreter")
|
||||||
|
msgs = [
|
||||||
|
str(w.message)
|
||||||
|
for w in captured
|
||||||
|
if issubclass(w.category, FutureWarning)
|
||||||
|
]
|
||||||
|
assert any("code_interpreter" in m and "code_runner" in m for m in msgs)
|
||||||
|
|
||||||
|
def test_alias_re_exports_same_classes(self):
|
||||||
|
from wrenn import code_interpreter as ci
|
||||||
|
|
||||||
|
assert ci.Capsule is Capsule
|
||||||
|
assert ci.AsyncCapsule is AsyncCapsule
|
||||||
|
assert ci.Execution is Execution
|
||||||
|
assert ci.Result is Result
|
||||||
|
|
||||||
|
def test_sandbox_attr_deprecated(self):
|
||||||
|
from wrenn import code_runner as cr
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as captured:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
S = cr.Sandbox
|
||||||
|
assert S is cr.Capsule
|
||||||
|
assert any(
|
||||||
|
issubclass(w.category, FutureWarning) and "Sandbox" in str(w.message)
|
||||||
|
for w in captured
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── Capsule (mock HTTP) ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def _make_capsule(capsule_id: str = "sb-1") -> Capsule:
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
202,
|
||||||
|
json={"id": capsule_id, "status": "starting", "template": DEFAULT_TEMPLATE},
|
||||||
|
)
|
||||||
|
return Capsule(api_key=API_KEY, base_url=BASE)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapsuleDefaults:
|
||||||
|
@respx.mock
|
||||||
|
def test_default_template_sent(self):
|
||||||
|
route = respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
202, json={"id": "sb-1", "status": "starting"}
|
||||||
|
)
|
||||||
|
Capsule(api_key=API_KEY, base_url=BASE)
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["template"] == DEFAULT_TEMPLATE
|
||||||
|
assert DEFAULT_TEMPLATE == "code-runner-beta"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_explicit_template_override(self):
|
||||||
|
route = respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
202, json={"id": "sb-1", "status": "starting"}
|
||||||
|
)
|
||||||
|
Capsule(template="other-template", api_key=API_KEY, base_url=BASE)
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["template"] == "other-template"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_create_classmethod(self):
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
202, json={"id": "sb-2", "status": "starting"}
|
||||||
|
)
|
||||||
|
c = Capsule.create(api_key=API_KEY, base_url=BASE)
|
||||||
|
assert c.capsule_id == "sb-2"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_default_kernel_name(self):
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
202, json={"id": "sb-1", "status": "starting"}
|
||||||
|
)
|
||||||
|
c = Capsule(api_key=API_KEY, base_url=BASE)
|
||||||
|
assert c._kernel_name == DEFAULT_KERNEL == "wrenn"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_custom_kernel_name(self):
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
202, json={"id": "sb-1", "status": "starting"}
|
||||||
|
)
|
||||||
|
c = Capsule(kernel="python3", api_key=API_KEY, base_url=BASE)
|
||||||
|
assert c._kernel_name == "python3"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCtorFailureSafe:
|
||||||
|
"""Bug regression: __del__ must not crash when ctor fails before
|
||||||
|
_proxy_client is initialised."""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_del_safe_when_ctor_fails(self):
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
404,
|
||||||
|
json={"error": {"code": "not_found", "message": "no template"}},
|
||||||
|
)
|
||||||
|
from wrenn.exceptions import WrennNotFoundError
|
||||||
|
|
||||||
|
with pytest.raises(WrennNotFoundError):
|
||||||
|
Capsule(api_key=API_KEY, base_url=BASE)
|
||||||
|
# If we got here without an AttributeError on __del__, we're good.
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_close_idempotent(self):
|
||||||
|
c = _make_capsule()
|
||||||
|
c.close()
|
||||||
|
c.close() # second call must not raise
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── _ensure_kernel ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureKernel:
|
||||||
|
@respx.mock
|
||||||
|
def test_creates_kernel_with_wrenn_name_when_none_exist(self):
|
||||||
|
c = _make_capsule()
|
||||||
|
proxy_base = "https://8888-sb-1.wrenn.dev"
|
||||||
|
list_route = respx.get(f"{proxy_base}/api/kernels").respond(200, json=[])
|
||||||
|
create_route = respx.post(f"{proxy_base}/api/kernels").respond(
|
||||||
|
201, json={"id": "k-new", "name": "wrenn"}
|
||||||
|
)
|
||||||
|
|
||||||
|
kid = c._ensure_kernel()
|
||||||
|
assert kid == "k-new"
|
||||||
|
# Body must request the wrenn kernelspec.
|
||||||
|
body = json.loads(create_route.calls[0].request.content)
|
||||||
|
assert body == {"name": "wrenn"}
|
||||||
|
assert list_route.called
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_reuses_existing_wrenn_kernel(self):
|
||||||
|
c = _make_capsule()
|
||||||
|
proxy_base = "https://8888-sb-1.wrenn.dev"
|
||||||
|
respx.get(f"{proxy_base}/api/kernels").respond(
|
||||||
|
200,
|
||||||
|
json=[
|
||||||
|
{"id": "k-other", "name": "python3"},
|
||||||
|
{"id": "k-wrenn", "name": "wrenn"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
create = respx.post(f"{proxy_base}/api/kernels").respond(201, json={})
|
||||||
|
kid = c._ensure_kernel()
|
||||||
|
assert kid == "k-wrenn"
|
||||||
|
assert not create.called
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_creates_when_only_other_kernels_exist(self):
|
||||||
|
c = _make_capsule()
|
||||||
|
proxy_base = "https://8888-sb-1.wrenn.dev"
|
||||||
|
respx.get(f"{proxy_base}/api/kernels").respond(
|
||||||
|
200, json=[{"id": "k-other", "name": "python3"}]
|
||||||
|
)
|
||||||
|
respx.post(f"{proxy_base}/api/kernels").respond(
|
||||||
|
201, json={"id": "k-new", "name": "wrenn"}
|
||||||
|
)
|
||||||
|
kid = c._ensure_kernel()
|
||||||
|
assert kid == "k-new"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_caches_kernel_id(self):
|
||||||
|
c = _make_capsule()
|
||||||
|
proxy_base = "https://8888-sb-1.wrenn.dev"
|
||||||
|
route = respx.get(f"{proxy_base}/api/kernels").respond(
|
||||||
|
200, json=[{"id": "k-1", "name": "wrenn"}]
|
||||||
|
)
|
||||||
|
c._ensure_kernel()
|
||||||
|
c._ensure_kernel()
|
||||||
|
assert route.call_count == 1
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_custom_kernel_name_sent(self):
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
202, json={"id": "sb-1", "status": "starting"}
|
||||||
|
)
|
||||||
|
c = Capsule(kernel="python3", api_key=API_KEY, base_url=BASE)
|
||||||
|
proxy_base = "https://8888-sb-1.wrenn.dev"
|
||||||
|
respx.get(f"{proxy_base}/api/kernels").respond(200, json=[])
|
||||||
|
create = respx.post(f"{proxy_base}/api/kernels").respond(
|
||||||
|
201, json={"id": "k-py", "name": "python3"}
|
||||||
|
)
|
||||||
|
c._ensure_kernel()
|
||||||
|
body = json.loads(create.calls[0].request.content)
|
||||||
|
assert body == {"name": "python3"}
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_retries_on_5xx_then_succeeds(self):
|
||||||
|
c = _make_capsule()
|
||||||
|
proxy_base = "https://8888-sb-1.wrenn.dev"
|
||||||
|
responses = [
|
||||||
|
httpx.Response(503),
|
||||||
|
httpx.Response(200, json=[{"id": "k-1", "name": "wrenn"}]),
|
||||||
|
]
|
||||||
|
respx.get(f"{proxy_base}/api/kernels").mock(side_effect=responses)
|
||||||
|
with patch("time.sleep"):
|
||||||
|
kid = c._ensure_kernel(jupyter_timeout=5)
|
||||||
|
assert kid == "k-1"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_raises_on_4xx(self):
|
||||||
|
c = _make_capsule()
|
||||||
|
proxy_base = "https://8888-sb-1.wrenn.dev"
|
||||||
|
respx.get(f"{proxy_base}/api/kernels").respond(401)
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
c._ensure_kernel(jupyter_timeout=2)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_timeout_raises(self):
|
||||||
|
c = _make_capsule()
|
||||||
|
proxy_base = "https://8888-sb-1.wrenn.dev"
|
||||||
|
respx.get(f"{proxy_base}/api/kernels").respond(503)
|
||||||
|
with patch("time.sleep"):
|
||||||
|
with pytest.raises(TimeoutError):
|
||||||
|
c._ensure_kernel(jupyter_timeout=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── build_execute_request ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestJupyterRequest:
|
||||||
|
def test_structure(self):
|
||||||
|
from wrenn.code_runner._protocol import build_execute_request
|
||||||
|
|
||||||
|
msg = build_execute_request("print(1)")
|
||||||
|
assert msg["channel"] == "shell"
|
||||||
|
assert msg["header"]["msg_type"] == "execute_request"
|
||||||
|
assert msg["content"]["code"] == "print(1)"
|
||||||
|
assert msg["content"]["silent"] is False
|
||||||
|
assert msg["content"]["store_history"] is True
|
||||||
|
assert msg["content"]["allow_stdin"] is False
|
||||||
|
assert msg["content"]["stop_on_error"] is True
|
||||||
|
# msg_id must be a uuid-shaped string
|
||||||
|
assert len(msg["header"]["msg_id"]) == 36
|
||||||
|
|
||||||
|
def test_unique_msg_id_per_call(self):
|
||||||
|
from wrenn.code_runner._protocol import build_execute_request
|
||||||
|
|
||||||
|
a = build_execute_request("x")
|
||||||
|
b = build_execute_request("x")
|
||||||
|
assert a["header"]["msg_id"] != b["header"]["msg_id"]
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── run_code (WS-mocked) ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap(msg_type: str, parent_id: str, content: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"msg_type": msg_type,
|
||||||
|
"header": {"msg_type": msg_type},
|
||||||
|
"parent_header": {"msg_id": parent_id},
|
||||||
|
"content": content,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeWS:
|
||||||
|
"""Minimal sync httpx_ws-shaped fake.
|
||||||
|
|
||||||
|
If ``frames_factory`` yields an ``Exception`` instance, the fake
|
||||||
|
raises it instead of returning the value — useful for testing
|
||||||
|
disconnect / network-error paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, frames_factory):
|
||||||
|
self._frames_factory = frames_factory
|
||||||
|
self._sent: list[str] = []
|
||||||
|
self._iter = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_text(self, s: str) -> None:
|
||||||
|
self._sent.append(s)
|
||||||
|
parent_id = json.loads(s)["header"]["msg_id"]
|
||||||
|
self._iter = iter(self._frames_factory(parent_id))
|
||||||
|
|
||||||
|
def receive_json(self, timeout: float = 0):
|
||||||
|
assert self._iter is not None
|
||||||
|
try:
|
||||||
|
nxt = next(self._iter)
|
||||||
|
except StopIteration:
|
||||||
|
raise TimeoutError("no more frames")
|
||||||
|
if isinstance(nxt, BaseException):
|
||||||
|
raise nxt
|
||||||
|
return nxt
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAsyncWS:
|
||||||
|
def __init__(self, frames_factory):
|
||||||
|
self._frames_factory = frames_factory
|
||||||
|
self._iter = None
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def send_text(self, s: str) -> None:
|
||||||
|
parent_id = json.loads(s)["header"]["msg_id"]
|
||||||
|
self._iter = iter(self._frames_factory(parent_id))
|
||||||
|
|
||||||
|
async def receive_json(self):
|
||||||
|
assert self._iter is not None
|
||||||
|
try:
|
||||||
|
nxt = next(self._iter)
|
||||||
|
except StopIteration:
|
||||||
|
raise TimeoutError("no more frames")
|
||||||
|
if isinstance(nxt, BaseException):
|
||||||
|
raise nxt
|
||||||
|
return nxt
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunCode:
|
||||||
|
@respx.mock
|
||||||
|
def _make_ready(self):
|
||||||
|
c = _make_capsule()
|
||||||
|
# Pre-populate kernel so run_code skips ensure.
|
||||||
|
c._kernel_id = "k-1"
|
||||||
|
return c
|
||||||
|
|
||||||
|
def test_stream_stdout_and_stderr(self):
|
||||||
|
c = self._make_ready()
|
||||||
|
|
||||||
|
def frames(pid):
|
||||||
|
yield _wrap("stream", pid, {"name": "stdout", "text": "hello\n"})
|
||||||
|
yield _wrap("stream", pid, {"name": "stderr", "text": "warn\n"})
|
||||||
|
yield _wrap("status", pid, {"execution_state": "idle"})
|
||||||
|
|
||||||
|
stdout_chunks, stderr_chunks = [], []
|
||||||
|
with patch(
|
||||||
|
"wrenn.code_runner.capsule.httpx_ws.connect_ws",
|
||||||
|
return_value=_FakeWS(frames),
|
||||||
|
):
|
||||||
|
ex = c.run_code(
|
||||||
|
"print('hello')",
|
||||||
|
on_stdout=stdout_chunks.append,
|
||||||
|
on_stderr=stderr_chunks.append,
|
||||||
|
)
|
||||||
|
assert ex.logs.stdout == ["hello\n"]
|
||||||
|
assert ex.logs.stderr == ["warn\n"]
|
||||||
|
assert stdout_chunks == ["hello\n"]
|
||||||
|
assert stderr_chunks == ["warn\n"]
|
||||||
|
assert ex.error is None
|
||||||
|
|
||||||
|
def test_execute_result_main_and_display_data(self):
|
||||||
|
c = self._make_ready()
|
||||||
|
|
||||||
|
def frames(pid):
|
||||||
|
yield _wrap(
|
||||||
|
"display_data",
|
||||||
|
pid,
|
||||||
|
{"data": {"image/png": "BASE64"}},
|
||||||
|
)
|
||||||
|
yield _wrap(
|
||||||
|
"execute_result",
|
||||||
|
pid,
|
||||||
|
{
|
||||||
|
"execution_count": 7,
|
||||||
|
"data": {"text/plain": "'42'"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
yield _wrap("status", pid, {"execution_state": "idle"})
|
||||||
|
|
||||||
|
results = []
|
||||||
|
with patch(
|
||||||
|
"wrenn.code_runner.capsule.httpx_ws.connect_ws",
|
||||||
|
return_value=_FakeWS(frames),
|
||||||
|
):
|
||||||
|
ex = c.run_code("'42'", on_result=results.append)
|
||||||
|
assert ex.execution_count == 7
|
||||||
|
assert len(ex.results) == 2
|
||||||
|
main = [r for r in ex.results if r.is_main_result]
|
||||||
|
assert len(main) == 1
|
||||||
|
assert main[0].text == "'42'" # text/plain preserved verbatim
|
||||||
|
display = [r for r in ex.results if not r.is_main_result]
|
||||||
|
assert display[0].png == "BASE64"
|
||||||
|
assert ex.text == "'42'"
|
||||||
|
assert len(results) == 2
|
||||||
|
|
||||||
|
def test_error_message(self):
|
||||||
|
c = self._make_ready()
|
||||||
|
|
||||||
|
def frames(pid):
|
||||||
|
yield _wrap(
|
||||||
|
"error",
|
||||||
|
pid,
|
||||||
|
{
|
||||||
|
"ename": "NameError",
|
||||||
|
"evalue": "name 'x' is not defined",
|
||||||
|
"traceback": ["line1", "line2"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
yield _wrap("status", pid, {"execution_state": "idle"})
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
with patch(
|
||||||
|
"wrenn.code_runner.capsule.httpx_ws.connect_ws",
|
||||||
|
return_value=_FakeWS(frames),
|
||||||
|
):
|
||||||
|
ex = c.run_code("x", on_error=errors.append)
|
||||||
|
assert ex.error is not None
|
||||||
|
assert ex.error.name == "NameError"
|
||||||
|
assert ex.error.value == "name 'x' is not defined"
|
||||||
|
assert ex.error.traceback == "line1\nline2"
|
||||||
|
assert len(errors) == 1
|
||||||
|
|
||||||
|
def test_ignores_frames_with_other_parent(self):
|
||||||
|
c = self._make_ready()
|
||||||
|
|
||||||
|
def frames(pid):
|
||||||
|
yield _wrap("stream", "other-id", {"name": "stdout", "text": "drop\n"})
|
||||||
|
yield _wrap("stream", pid, {"name": "stdout", "text": "keep\n"})
|
||||||
|
yield _wrap("status", pid, {"execution_state": "idle"})
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"wrenn.code_runner.capsule.httpx_ws.connect_ws",
|
||||||
|
return_value=_FakeWS(frames),
|
||||||
|
):
|
||||||
|
ex = c.run_code("print('keep')")
|
||||||
|
assert ex.logs.stdout == ["keep\n"]
|
||||||
|
|
||||||
|
def test_unsupported_language_raises(self):
|
||||||
|
c = self._make_ready()
|
||||||
|
with pytest.raises(ValueError, match="not supported"):
|
||||||
|
c.run_code("console.log('x')", language="javascript")
|
||||||
|
|
||||||
|
def test_idle_status_terminates_loop(self):
|
||||||
|
c = self._make_ready()
|
||||||
|
called = {"n": 0}
|
||||||
|
|
||||||
|
def frames(pid):
|
||||||
|
yield _wrap("status", pid, {"execution_state": "idle"})
|
||||||
|
# Following frame must never be consumed.
|
||||||
|
called["n"] += 1
|
||||||
|
yield _wrap("stream", pid, {"name": "stdout", "text": "post-idle\n"})
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"wrenn.code_runner.capsule.httpx_ws.connect_ws",
|
||||||
|
return_value=_FakeWS(frames),
|
||||||
|
):
|
||||||
|
ex = c.run_code("pass")
|
||||||
|
assert ex.logs.stdout == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncRunCode:
|
||||||
|
@respx.mock
|
||||||
|
def _make_ready(self):
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
202, json={"id": "sb-1", "status": "starting"}
|
||||||
|
)
|
||||||
|
from wrenn.client import AsyncWrennClient
|
||||||
|
from wrenn.models import Capsule as CapsuleModel
|
||||||
|
|
||||||
|
client = AsyncWrennClient(api_key=API_KEY, base_url=BASE)
|
||||||
|
info = CapsuleModel(id="sb-1")
|
||||||
|
c = AsyncCapsule(_capsule_id="sb-1", _client=client, _info=info)
|
||||||
|
c._kernel_id = "k-1"
|
||||||
|
return c
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_and_result(self):
|
||||||
|
c = self._make_ready()
|
||||||
|
|
||||||
|
def frames(pid):
|
||||||
|
yield _wrap("stream", pid, {"name": "stdout", "text": "hi\n"})
|
||||||
|
yield _wrap(
|
||||||
|
"execute_result",
|
||||||
|
pid,
|
||||||
|
{"execution_count": 1, "data": {"text/plain": "7"}},
|
||||||
|
)
|
||||||
|
yield _wrap("status", pid, {"execution_state": "idle"})
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"wrenn.code_runner.async_capsule.httpx_ws.aconnect_ws",
|
||||||
|
return_value=_FakeAsyncWS(frames),
|
||||||
|
):
|
||||||
|
ex = await c.run_code("7")
|
||||||
|
assert ex.logs.stdout == ["hi\n"]
|
||||||
|
assert ex.text == "7"
|
||||||
|
assert ex.execution_count == 1
|
||||||
|
await c.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_default_kernel(self):
|
||||||
|
c = self._make_ready()
|
||||||
|
assert c._kernel_name == "wrenn"
|
||||||
|
await c.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncCtorFailureSafe:
|
||||||
|
def test_del_safe_when_not_constructed(self):
|
||||||
|
# Build without ever calling __init__'s parent path that needs network,
|
||||||
|
# by hand-poking attributes the way create() failure would leave them.
|
||||||
|
c = AsyncCapsule.__new__(AsyncCapsule)
|
||||||
|
# __del__ should be safe even with no attrs.
|
||||||
|
c.__del__()
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── run_code error-path regressions (B2) ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunCodeErrorPaths:
|
||||||
|
"""Sync run_code timeout / disconnect / unexpected-exception behavior."""
|
||||||
|
|
||||||
|
def _ready(self):
|
||||||
|
return TestRunCode()._make_ready()
|
||||||
|
|
||||||
|
def test_timeout_when_no_idle_received(self):
|
||||||
|
c = self._ready()
|
||||||
|
|
||||||
|
def frames(pid):
|
||||||
|
yield _wrap("stream", pid, {"name": "stdout", "text": "partial\n"})
|
||||||
|
# No idle frame; loop exits via StopIteration → TimeoutError.
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
with patch(
|
||||||
|
"wrenn.code_runner.capsule.httpx_ws.connect_ws",
|
||||||
|
return_value=_FakeWS(frames),
|
||||||
|
):
|
||||||
|
ex = c.run_code("x", on_error=errors.append)
|
||||||
|
assert ex.timed_out is True
|
||||||
|
assert ex.error is not None
|
||||||
|
assert ex.error.name == "Timeout"
|
||||||
|
assert "exceeded" in ex.error.value
|
||||||
|
assert ex.logs.stdout == ["partial\n"]
|
||||||
|
assert len(errors) == 1
|
||||||
|
|
||||||
|
def test_disconnect_sets_disconnected_error(self):
|
||||||
|
c = self._ready()
|
||||||
|
import httpx_ws
|
||||||
|
|
||||||
|
def frames(pid):
|
||||||
|
yield _wrap("stream", pid, {"name": "stdout", "text": "hi\n"})
|
||||||
|
yield httpx_ws.WebSocketDisconnect(code=1000, reason="bye")
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
with patch(
|
||||||
|
"wrenn.code_runner.capsule.httpx_ws.connect_ws",
|
||||||
|
return_value=_FakeWS(frames),
|
||||||
|
):
|
||||||
|
ex = c.run_code("x", on_error=errors.append)
|
||||||
|
assert ex.timed_out is True
|
||||||
|
assert ex.error is not None
|
||||||
|
assert ex.error.name == "Disconnected"
|
||||||
|
assert ex.logs.stdout == ["hi\n"]
|
||||||
|
assert len(errors) == 1
|
||||||
|
|
||||||
|
def test_unexpected_exception_propagates(self):
|
||||||
|
c = self._ready()
|
||||||
|
|
||||||
|
def frames(pid):
|
||||||
|
yield RuntimeError("WS broken in unexpected way")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"wrenn.code_runner.capsule.httpx_ws.connect_ws",
|
||||||
|
return_value=_FakeWS(frames),
|
||||||
|
):
|
||||||
|
with pytest.raises(RuntimeError, match="WS broken"):
|
||||||
|
c.run_code("x")
|
||||||
|
|
||||||
|
def test_clean_exit_does_not_set_timed_out(self):
|
||||||
|
c = self._ready()
|
||||||
|
|
||||||
|
def frames(pid):
|
||||||
|
yield _wrap("status", pid, {"execution_state": "idle"})
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"wrenn.code_runner.capsule.httpx_ws.connect_ws",
|
||||||
|
return_value=_FakeWS(frames),
|
||||||
|
):
|
||||||
|
ex = c.run_code("pass")
|
||||||
|
assert ex.timed_out is False
|
||||||
|
assert ex.error is None
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── Async run_code parity ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncRunCodeErrorPaths:
|
||||||
|
def _ready(self):
|
||||||
|
return TestAsyncRunCode()._make_ready()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_timeout_when_no_idle(self):
|
||||||
|
c = self._ready()
|
||||||
|
|
||||||
|
def frames(pid):
|
||||||
|
yield _wrap("stream", pid, {"name": "stdout", "text": "partial\n"})
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
with patch(
|
||||||
|
"wrenn.code_runner.async_capsule.httpx_ws.aconnect_ws",
|
||||||
|
return_value=_FakeAsyncWS(frames),
|
||||||
|
):
|
||||||
|
ex = await c.run_code("x", on_error=errors.append)
|
||||||
|
assert ex.timed_out is True
|
||||||
|
assert ex.error is not None
|
||||||
|
assert ex.error.name == "Timeout"
|
||||||
|
assert ex.logs.stdout == ["partial\n"]
|
||||||
|
assert len(errors) == 1
|
||||||
|
await c.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_disconnect_sets_disconnected_error(self):
|
||||||
|
c = self._ready()
|
||||||
|
import httpx_ws
|
||||||
|
|
||||||
|
def frames(pid):
|
||||||
|
yield httpx_ws.WebSocketNetworkError("network blip")
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
with patch(
|
||||||
|
"wrenn.code_runner.async_capsule.httpx_ws.aconnect_ws",
|
||||||
|
return_value=_FakeAsyncWS(frames),
|
||||||
|
):
|
||||||
|
ex = await c.run_code("x", on_error=errors.append)
|
||||||
|
assert ex.timed_out is True
|
||||||
|
assert ex.error is not None
|
||||||
|
assert ex.error.name == "Disconnected"
|
||||||
|
assert len(errors) == 1
|
||||||
|
await c.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_unexpected_exception_propagates(self):
|
||||||
|
c = self._ready()
|
||||||
|
|
||||||
|
def frames(pid):
|
||||||
|
yield RuntimeError("unexpected WS death")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"wrenn.code_runner.async_capsule.httpx_ws.aconnect_ws",
|
||||||
|
return_value=_FakeAsyncWS(frames),
|
||||||
|
):
|
||||||
|
with pytest.raises(RuntimeError, match="unexpected WS"):
|
||||||
|
await c.run_code("x")
|
||||||
|
await c.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_unsupported_language_raises(self):
|
||||||
|
c = self._ready()
|
||||||
|
with pytest.raises(ValueError, match="not supported"):
|
||||||
|
await c.run_code("console.log('x')", language="javascript")
|
||||||
|
await c.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── Async _ensure_kernel parity ───────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def _make_async_capsule(capsule_id: str = "sb-1") -> AsyncCapsule:
|
||||||
|
"""Construct an AsyncCapsule without going through ``create()``."""
|
||||||
|
from wrenn.client import AsyncWrennClient
|
||||||
|
from wrenn.models import Capsule as CapsuleModel
|
||||||
|
|
||||||
|
client = AsyncWrennClient(api_key=API_KEY, base_url=BASE)
|
||||||
|
info = CapsuleModel(id=capsule_id)
|
||||||
|
return AsyncCapsule(_capsule_id=capsule_id, _client=client, _info=info)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncEnsureKernel:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_creates_kernel_when_none_exist(self):
|
||||||
|
c = _make_async_capsule()
|
||||||
|
proxy_base = "https://8888-sb-1.wrenn.dev"
|
||||||
|
list_route = respx.get(f"{proxy_base}/api/kernels").respond(200, json=[])
|
||||||
|
create_route = respx.post(f"{proxy_base}/api/kernels").respond(
|
||||||
|
201, json={"id": "k-new", "name": "wrenn"}
|
||||||
|
)
|
||||||
|
kid = await c._ensure_kernel()
|
||||||
|
assert kid == "k-new"
|
||||||
|
body = json.loads(create_route.calls[0].request.content)
|
||||||
|
assert body == {"name": "wrenn"}
|
||||||
|
assert list_route.called
|
||||||
|
await c.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_reuses_existing_wrenn_kernel(self):
|
||||||
|
c = _make_async_capsule()
|
||||||
|
proxy_base = "https://8888-sb-1.wrenn.dev"
|
||||||
|
respx.get(f"{proxy_base}/api/kernels").respond(
|
||||||
|
200,
|
||||||
|
json=[
|
||||||
|
{"id": "k-other", "name": "python3"},
|
||||||
|
{"id": "k-wrenn", "name": "wrenn"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
create = respx.post(f"{proxy_base}/api/kernels").respond(201, json={})
|
||||||
|
kid = await c._ensure_kernel()
|
||||||
|
assert kid == "k-wrenn"
|
||||||
|
assert not create.called
|
||||||
|
await c.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_retries_on_5xx_then_succeeds(self):
|
||||||
|
c = _make_async_capsule()
|
||||||
|
proxy_base = "https://8888-sb-1.wrenn.dev"
|
||||||
|
responses = [
|
||||||
|
httpx.Response(503),
|
||||||
|
httpx.Response(200, json=[{"id": "k-1", "name": "wrenn"}]),
|
||||||
|
]
|
||||||
|
respx.get(f"{proxy_base}/api/kernels").mock(side_effect=responses)
|
||||||
|
with patch("asyncio.sleep") as sleep_mock:
|
||||||
|
|
||||||
|
async def _noop(_s):
|
||||||
|
return None
|
||||||
|
|
||||||
|
sleep_mock.side_effect = _noop
|
||||||
|
kid = await c._ensure_kernel(jupyter_timeout=5)
|
||||||
|
assert kid == "k-1"
|
||||||
|
await c.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_raises_on_4xx(self):
|
||||||
|
c = _make_async_capsule()
|
||||||
|
proxy_base = "https://8888-sb-1.wrenn.dev"
|
||||||
|
respx.get(f"{proxy_base}/api/kernels").respond(401)
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
await c._ensure_kernel(jupyter_timeout=2)
|
||||||
|
await c.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_caches_kernel_id(self):
|
||||||
|
c = _make_async_capsule()
|
||||||
|
proxy_base = "https://8888-sb-1.wrenn.dev"
|
||||||
|
route = respx.get(f"{proxy_base}/api/kernels").respond(
|
||||||
|
200, json=[{"id": "k-1", "name": "wrenn"}]
|
||||||
|
)
|
||||||
|
await c._ensure_kernel()
|
||||||
|
await c._ensure_kernel()
|
||||||
|
assert route.call_count == 1
|
||||||
|
await c.close()
|
||||||
490
tests/test_commands.py
Normal file
490
tests/test_commands.py
Normal file
@ -0,0 +1,490 @@
|
|||||||
|
"""Unit tests for wrenn.commands — Commands / AsyncCommands.
|
||||||
|
|
||||||
|
Covers payload construction (cwd, envs, tag, timeout), foreground/background
|
||||||
|
dispatch, base64 response decoding, stream-event parsing, and the
|
||||||
|
WebSocket-backed ``stream`` / ``connect`` iterators (with a fake WS).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from contextlib import asynccontextmanager, contextmanager
|
||||||
|
|
||||||
|
import httpx_ws
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from wrenn.client import AsyncWrennClient, WrennClient
|
||||||
|
from wrenn.commands import (
|
||||||
|
AsyncCommands,
|
||||||
|
CommandHandle,
|
||||||
|
CommandResult,
|
||||||
|
Commands,
|
||||||
|
ProcessInfo,
|
||||||
|
StreamErrorEvent,
|
||||||
|
StreamEvent,
|
||||||
|
StreamExitEvent,
|
||||||
|
StreamStartEvent,
|
||||||
|
StreamStderrEvent,
|
||||||
|
StreamStdoutEvent,
|
||||||
|
_decode_exec_response,
|
||||||
|
_parse_stream_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
BASE = "https://app.wrenn.dev/api"
|
||||||
|
CAPSULE_ID = "cl-cmd123"
|
||||||
|
EXEC_URL = f"{BASE}/v1/capsules/{CAPSULE_ID}/exec"
|
||||||
|
PROC_URL = f"{BASE}/v1/capsules/{CAPSULE_ID}/processes"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_commands() -> Commands:
|
||||||
|
client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||||
|
return Commands(CAPSULE_ID, client.http)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_async_commands() -> AsyncCommands:
|
||||||
|
client = AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||||
|
return AsyncCommands(CAPSULE_ID, client.http)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _decode_exec_response ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecodeExecResponse:
|
||||||
|
def test_plain_text(self):
|
||||||
|
result = _decode_exec_response(
|
||||||
|
{"stdout": "hello\n", "stderr": "", "exit_code": 0, "duration_ms": 12}
|
||||||
|
)
|
||||||
|
assert isinstance(result, CommandResult)
|
||||||
|
assert result.stdout == "hello\n"
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.duration_ms == 12
|
||||||
|
|
||||||
|
def test_base64_stdout(self):
|
||||||
|
encoded = base64.b64encode(b"binary\xff\x00out").decode()
|
||||||
|
result = _decode_exec_response(
|
||||||
|
{"stdout": encoded, "encoding": "base64", "exit_code": 0}
|
||||||
|
)
|
||||||
|
assert "binary" in result.stdout
|
||||||
|
|
||||||
|
def test_base64_stderr(self):
|
||||||
|
out = base64.b64encode(b"ok").decode()
|
||||||
|
err = base64.b64encode(b"warning").decode()
|
||||||
|
result = _decode_exec_response(
|
||||||
|
{"stdout": out, "stderr": err, "encoding": "base64", "exit_code": 1}
|
||||||
|
)
|
||||||
|
assert result.stdout == "ok"
|
||||||
|
assert result.stderr == "warning"
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
def test_missing_fields_default(self):
|
||||||
|
result = _decode_exec_response({})
|
||||||
|
assert result.stdout == ""
|
||||||
|
assert result.stderr == ""
|
||||||
|
assert result.exit_code == -1
|
||||||
|
assert result.duration_ms is None
|
||||||
|
|
||||||
|
def test_null_stdout_coerced_to_empty(self):
|
||||||
|
result = _decode_exec_response({"stdout": None, "stderr": None})
|
||||||
|
assert result.stdout == ""
|
||||||
|
assert result.stderr == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── _parse_stream_event ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseStreamEvent:
|
||||||
|
def test_start(self):
|
||||||
|
event = _parse_stream_event({"type": "start", "pid": 99})
|
||||||
|
assert isinstance(event, StreamStartEvent)
|
||||||
|
assert event.type == "start"
|
||||||
|
assert event.pid == 99
|
||||||
|
|
||||||
|
def test_stdout(self):
|
||||||
|
event = _parse_stream_event({"type": "stdout", "data": "out"})
|
||||||
|
assert isinstance(event, StreamStdoutEvent)
|
||||||
|
assert event.data == "out"
|
||||||
|
|
||||||
|
def test_stderr(self):
|
||||||
|
event = _parse_stream_event({"type": "stderr", "data": "err"})
|
||||||
|
assert isinstance(event, StreamStderrEvent)
|
||||||
|
assert event.data == "err"
|
||||||
|
|
||||||
|
def test_exit(self):
|
||||||
|
event = _parse_stream_event({"type": "exit", "exit_code": 7})
|
||||||
|
assert isinstance(event, StreamExitEvent)
|
||||||
|
assert event.exit_code == 7
|
||||||
|
|
||||||
|
def test_error(self):
|
||||||
|
event = _parse_stream_event({"type": "error", "data": "boom"})
|
||||||
|
assert isinstance(event, StreamErrorEvent)
|
||||||
|
assert event.data == "boom"
|
||||||
|
|
||||||
|
def test_unknown_type(self):
|
||||||
|
event = _parse_stream_event({"type": "weird"})
|
||||||
|
assert isinstance(event, StreamEvent)
|
||||||
|
assert event.type == "weird"
|
||||||
|
|
||||||
|
def test_missing_type(self):
|
||||||
|
event = _parse_stream_event({})
|
||||||
|
assert event.type == "unknown"
|
||||||
|
|
||||||
|
def test_exit_missing_code_defaults(self):
|
||||||
|
event = _parse_stream_event({"type": "exit"})
|
||||||
|
assert isinstance(event, StreamExitEvent)
|
||||||
|
assert event.exit_code == -1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Commands.run — payload construction ───────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunPayload:
|
||||||
|
@respx.mock
|
||||||
|
def test_foreground_basic_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"stdout": "hi", "exit_code": 0})
|
||||||
|
result = _make_commands().run("echo hi")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["cmd"] == "/bin/sh"
|
||||||
|
assert body["args"] == ["-c", "echo hi"]
|
||||||
|
assert body["background"] is False
|
||||||
|
assert body["timeout_sec"] == 30
|
||||||
|
assert result.stdout == "hi"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_cwd_in_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("pwd", cwd="/tmp/work")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["cwd"] == "/tmp/work"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_cwd_omitted_when_none(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("pwd")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert "cwd" not in body
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_envs_in_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("env", envs={"FOO": "bar", "BAZ": "qux"})
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["envs"] == {"FOO": "bar", "BAZ": "qux"}
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_empty_envs_still_sent(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("env", envs={})
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["envs"] == {}
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_tag_in_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("echo x", tag="my-tag")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["tag"] == "my-tag"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_custom_timeout_in_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("sleep 1", timeout=120)
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["timeout_sec"] == 120
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_timeout_none_omits_field(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("echo x", timeout=None)
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert "timeout_sec" not in body
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_all_kwargs_combined(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("echo x", timeout=60, envs={"A": "1"}, cwd="/srv", tag="t")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["cwd"] == "/srv"
|
||||||
|
assert body["envs"] == {"A": "1"}
|
||||||
|
assert body["tag"] == "t"
|
||||||
|
assert body["timeout_sec"] == 60
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunBackground:
|
||||||
|
@respx.mock
|
||||||
|
def test_background_returns_handle(self):
|
||||||
|
respx.post(EXEC_URL).respond(200, json={"pid": 1234, "tag": "bg"})
|
||||||
|
handle = _make_commands().run("sleep 100", background=True)
|
||||||
|
assert isinstance(handle, CommandHandle)
|
||||||
|
assert handle.pid == 1234
|
||||||
|
assert handle.tag == "bg"
|
||||||
|
assert handle.capsule_id == CAPSULE_ID
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_background_omits_timeout_sec(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"pid": 1, "tag": "x"})
|
||||||
|
_make_commands().run("sleep 100", background=True, timeout=30)
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert "timeout_sec" not in body
|
||||||
|
assert body["background"] is True
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_background_carries_cwd_and_envs(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"pid": 5, "tag": "t"})
|
||||||
|
_make_commands().run(
|
||||||
|
"server", background=True, cwd="/app", envs={"PORT": "80"}, tag="srv"
|
||||||
|
)
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["cwd"] == "/app"
|
||||||
|
assert body["envs"] == {"PORT": "80"}
|
||||||
|
assert body["tag"] == "srv"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_background_missing_pid_defaults_zero(self):
|
||||||
|
respx.post(EXEC_URL).respond(200, json={"tag": "x"})
|
||||||
|
handle = _make_commands().run("x", background=True)
|
||||||
|
assert handle.pid == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestListAndKill:
|
||||||
|
@respx.mock
|
||||||
|
def test_list_parses_processes(self):
|
||||||
|
respx.get(PROC_URL).respond(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"processes": [
|
||||||
|
{
|
||||||
|
"pid": 10,
|
||||||
|
"tag": "web",
|
||||||
|
"cmd": "/bin/sh",
|
||||||
|
"args": ["-c", "serve"],
|
||||||
|
},
|
||||||
|
{"pid": 11},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
procs = _make_commands().list()
|
||||||
|
assert len(procs) == 2
|
||||||
|
assert isinstance(procs[0], ProcessInfo)
|
||||||
|
assert procs[0].pid == 10
|
||||||
|
assert procs[0].tag == "web"
|
||||||
|
assert procs[0].args == ["-c", "serve"]
|
||||||
|
assert procs[1].pid == 11
|
||||||
|
assert procs[1].tag is None
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_list_empty(self):
|
||||||
|
respx.get(PROC_URL).respond(200, json={"processes": []})
|
||||||
|
assert _make_commands().list() == []
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_list_missing_key(self):
|
||||||
|
respx.get(PROC_URL).respond(200, json={})
|
||||||
|
assert _make_commands().list() == []
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_kill_sends_delete(self):
|
||||||
|
route = respx.delete(f"{PROC_URL}/42").respond(204)
|
||||||
|
_make_commands().kill(42)
|
||||||
|
assert route.called
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_kill_unknown_pid_raises(self):
|
||||||
|
from wrenn.exceptions import WrennNotFoundError
|
||||||
|
|
||||||
|
respx.delete(f"{PROC_URL}/999").respond(
|
||||||
|
404, json={"error": {"code": "not_found", "message": "no such process"}}
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennNotFoundError):
|
||||||
|
_make_commands().kill(999)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fake WebSocket plumbing for stream / connect ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeWS:
|
||||||
|
"""Synchronous fake WebSocket session."""
|
||||||
|
|
||||||
|
def __init__(self, messages: list) -> None:
|
||||||
|
self._messages = list(messages)
|
||||||
|
self.sent: list[str] = []
|
||||||
|
|
||||||
|
def send_text(self, text: str) -> None:
|
||||||
|
self.sent.append(text)
|
||||||
|
|
||||||
|
def receive_json(self) -> dict:
|
||||||
|
if not self._messages:
|
||||||
|
raise httpx_ws.WebSocketDisconnect()
|
||||||
|
msg = self._messages.pop(0)
|
||||||
|
if isinstance(msg, Exception):
|
||||||
|
raise msg
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
class _AsyncFakeWS:
|
||||||
|
"""Asynchronous fake WebSocket session."""
|
||||||
|
|
||||||
|
def __init__(self, messages: list) -> None:
|
||||||
|
self._messages = list(messages)
|
||||||
|
self.sent: list[str] = []
|
||||||
|
|
||||||
|
async def send_text(self, text: str) -> None:
|
||||||
|
self.sent.append(text)
|
||||||
|
|
||||||
|
async def receive_json(self) -> dict:
|
||||||
|
if not self._messages:
|
||||||
|
raise httpx_ws.WebSocketDisconnect()
|
||||||
|
msg = self._messages.pop(0)
|
||||||
|
if isinstance(msg, Exception):
|
||||||
|
raise msg
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_sync_ws(monkeypatch, ws: _FakeWS) -> None:
|
||||||
|
@contextmanager
|
||||||
|
def _fake_connect(url, client):
|
||||||
|
yield ws
|
||||||
|
|
||||||
|
monkeypatch.setattr("wrenn.commands.httpx_ws.connect_ws", _fake_connect)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_async_ws(monkeypatch, ws: _AsyncFakeWS) -> None:
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _fake_aconnect(url, client):
|
||||||
|
yield ws
|
||||||
|
|
||||||
|
monkeypatch.setattr("wrenn.commands.httpx_ws.aconnect_ws", _fake_aconnect)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Commands.stream ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestStream:
|
||||||
|
def test_stream_sends_shell_wrapped_start(self, monkeypatch):
|
||||||
|
ws = _FakeWS([{"type": "exit", "exit_code": 0}])
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
list(_make_commands().stream("echo hi"))
|
||||||
|
start = json.loads(ws.sent[0])
|
||||||
|
assert start == {"type": "start", "cmd": "/bin/sh", "args": ["-c", "echo hi"]}
|
||||||
|
|
||||||
|
def test_stream_with_explicit_args(self, monkeypatch):
|
||||||
|
ws = _FakeWS([{"type": "exit", "exit_code": 0}])
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
list(_make_commands().stream("/usr/bin/env", args=["python", "-V"]))
|
||||||
|
start = json.loads(ws.sent[0])
|
||||||
|
assert start == {
|
||||||
|
"type": "start",
|
||||||
|
"cmd": "/usr/bin/env",
|
||||||
|
"args": ["python", "-V"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_stream_yields_events_until_exit(self, monkeypatch):
|
||||||
|
ws = _FakeWS(
|
||||||
|
[
|
||||||
|
{"type": "start", "pid": 3},
|
||||||
|
{"type": "stdout", "data": "line1"},
|
||||||
|
{"type": "stderr", "data": "warn"},
|
||||||
|
{"type": "exit", "exit_code": 0},
|
||||||
|
{"type": "stdout", "data": "after-exit-ignored"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
events = list(_make_commands().stream("echo line1"))
|
||||||
|
assert [e.type for e in events] == ["start", "stdout", "stderr", "exit"]
|
||||||
|
|
||||||
|
def test_stream_stops_on_error(self, monkeypatch):
|
||||||
|
ws = _FakeWS([{"type": "error", "data": "fatal"}])
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
events = list(_make_commands().stream("bad"))
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].type == "error"
|
||||||
|
|
||||||
|
def test_stream_handles_disconnect(self, monkeypatch):
|
||||||
|
ws = _FakeWS([{"type": "stdout", "data": "x"}]) # then disconnect
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
events = list(_make_commands().stream("echo x"))
|
||||||
|
assert [e.type for e in events] == ["stdout"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Commands.connect ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestConnect:
|
||||||
|
def test_connect_yields_until_exit(self, monkeypatch):
|
||||||
|
ws = _FakeWS(
|
||||||
|
[
|
||||||
|
{"type": "stdout", "data": "tick"},
|
||||||
|
{"type": "exit", "exit_code": 0},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
events = list(_make_commands().connect(55))
|
||||||
|
assert [e.type for e in events] == ["stdout", "exit"]
|
||||||
|
|
||||||
|
def test_connect_handles_disconnect(self, monkeypatch):
|
||||||
|
ws = _FakeWS([]) # immediate disconnect
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
assert list(_make_commands().connect(1)) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── AsyncCommands ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncCommands:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_run_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"stdout": "hi", "exit_code": 0})
|
||||||
|
cmds = _make_async_commands()
|
||||||
|
result = await cmds.run("echo hi", cwd="/tmp", envs={"K": "v"}, tag="z")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["cwd"] == "/tmp"
|
||||||
|
assert body["envs"] == {"K": "v"}
|
||||||
|
assert body["tag"] == "z"
|
||||||
|
assert result.stdout == "hi"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_run_background(self):
|
||||||
|
respx.post(EXEC_URL).respond(200, json={"pid": 7, "tag": "bg"})
|
||||||
|
handle = await _make_async_commands().run("sleep 1", background=True)
|
||||||
|
assert isinstance(handle, CommandHandle)
|
||||||
|
assert handle.pid == 7
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_list(self):
|
||||||
|
respx.get(PROC_URL).respond(200, json={"processes": [{"pid": 1, "tag": "a"}]})
|
||||||
|
procs = await _make_async_commands().list()
|
||||||
|
assert len(procs) == 1
|
||||||
|
assert procs[0].pid == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_kill(self):
|
||||||
|
route = respx.delete(f"{PROC_URL}/3").respond(204)
|
||||||
|
await _make_async_commands().kill(3)
|
||||||
|
assert route.called
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_stream(self, monkeypatch):
|
||||||
|
ws = _AsyncFakeWS(
|
||||||
|
[
|
||||||
|
{"type": "start", "pid": 1},
|
||||||
|
{"type": "stdout", "data": "out"},
|
||||||
|
{"type": "exit", "exit_code": 0},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
_patch_async_ws(monkeypatch, ws)
|
||||||
|
events = [e async for e in _make_async_commands().stream("echo out")]
|
||||||
|
assert [e.type for e in events] == ["start", "stdout", "exit"]
|
||||||
|
start = json.loads(ws.sent[0])
|
||||||
|
assert start["cmd"] == "/bin/sh"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_connect(self, monkeypatch):
|
||||||
|
ws = _AsyncFakeWS([{"type": "exit", "exit_code": 0}])
|
||||||
|
_patch_async_ws(monkeypatch, ws)
|
||||||
|
events = [e async for e in _make_async_commands().connect(9)]
|
||||||
|
assert [e.type for e in events] == ["exit"]
|
||||||
@ -74,32 +74,32 @@ class TestFilesList:
|
|||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"name": "main.py",
|
"name": "main.py",
|
||||||
"path": "/home/user/main.py",
|
"path": "/home/wrenn-user/main.py",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"size": 1024,
|
"size": 1024,
|
||||||
"mode": 33188,
|
"mode": 33188,
|
||||||
"permissions": "-rw-r--r--",
|
"permissions": "-rw-r--r--",
|
||||||
"owner": "root",
|
"owner": "wrenn-user",
|
||||||
"group": "root",
|
"group": "wrenn-user",
|
||||||
"modified_at": 1712899200,
|
"modified_at": 1712899200,
|
||||||
"symlink_target": None,
|
"symlink_target": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "config",
|
"name": "config",
|
||||||
"path": "/home/user/config",
|
"path": "/home/wrenn-user/config",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"size": 4096,
|
"size": 4096,
|
||||||
"mode": 16877,
|
"mode": 16877,
|
||||||
"permissions": "drwxr-xr-x",
|
"permissions": "drwxr-xr-x",
|
||||||
"owner": "root",
|
"owner": "wrenn-user",
|
||||||
"group": "root",
|
"group": "wrenn-user",
|
||||||
"modified_at": 1712899100,
|
"modified_at": 1712899100,
|
||||||
"symlink_target": None,
|
"symlink_target": None,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
entries = cap.files.list("/home/user")
|
entries = cap.files.list("/home/wrenn-user")
|
||||||
assert len(entries) == 2
|
assert len(entries) == 2
|
||||||
assert isinstance(entries[0], FileEntry)
|
assert isinstance(entries[0], FileEntry)
|
||||||
assert entries[0].name == "main.py"
|
assert entries[0].name == "main.py"
|
||||||
@ -113,7 +113,7 @@ class TestFilesList:
|
|||||||
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||||
200, json={"entries": []}
|
200, json={"entries": []}
|
||||||
)
|
)
|
||||||
cap.files.list("/home/user", depth=3)
|
cap.files.list("/home/wrenn-user", depth=3)
|
||||||
body = json.loads(route.calls[0].request.content)
|
body = json.loads(route.calls[0].request.content)
|
||||||
assert body["depth"] == 3
|
assert body["depth"] == 3
|
||||||
|
|
||||||
@ -136,19 +136,19 @@ class TestFilesMakeDir:
|
|||||||
json={
|
json={
|
||||||
"entry": {
|
"entry": {
|
||||||
"name": "data",
|
"name": "data",
|
||||||
"path": "/home/user/data",
|
"path": "/home/wrenn-user/data",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"size": 4096,
|
"size": 4096,
|
||||||
"mode": 16877,
|
"mode": 16877,
|
||||||
"permissions": "drwxr-xr-x",
|
"permissions": "drwxr-xr-x",
|
||||||
"owner": "root",
|
"owner": "wrenn-user",
|
||||||
"group": "root",
|
"group": "wrenn-user",
|
||||||
"modified_at": 1712899200,
|
"modified_at": 1712899200,
|
||||||
"symlink_target": None,
|
"symlink_target": None,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
entry = cap.files.make_dir("/home/user/data")
|
entry = cap.files.make_dir("/home/wrenn-user/data")
|
||||||
assert isinstance(entry, FileEntry)
|
assert isinstance(entry, FileEntry)
|
||||||
assert entry.name == "data"
|
assert entry.name == "data"
|
||||||
assert entry.type == "directory"
|
assert entry.type == "directory"
|
||||||
@ -166,20 +166,20 @@ class TestFilesMakeDir:
|
|||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"name": "data",
|
"name": "data",
|
||||||
"path": "/home/user/data",
|
"path": "/home/wrenn-user/data",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"size": 4096,
|
"size": 4096,
|
||||||
"mode": 16877,
|
"mode": 16877,
|
||||||
"permissions": "drwxr-xr-x",
|
"permissions": "drwxr-xr-x",
|
||||||
"owner": "root",
|
"owner": "wrenn-user",
|
||||||
"group": "root",
|
"group": "wrenn-user",
|
||||||
"modified_at": 1712899200,
|
"modified_at": 1712899200,
|
||||||
"symlink_target": None,
|
"symlink_target": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
entry = cap.files.make_dir("/home/user/data")
|
entry = cap.files.make_dir("/home/wrenn-user/data")
|
||||||
assert entry.name == "data"
|
assert entry.name == "data"
|
||||||
|
|
||||||
|
|
||||||
@ -188,7 +188,7 @@ class TestFilesRemove:
|
|||||||
def test_remove_succeeds(self):
|
def test_remove_succeeds(self):
|
||||||
cap = _make_capsule()
|
cap = _make_capsule()
|
||||||
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204)
|
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204)
|
||||||
cap.files.remove("/home/user/old_data")
|
cap.files.remove("/home/wrenn-user/old_data")
|
||||||
assert route.called
|
assert route.called
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
@ -341,6 +341,39 @@ class TestPtySessionIteration:
|
|||||||
assert events == []
|
assert events == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestPtySessionPong:
|
||||||
|
def test_ping_triggers_pong(self):
|
||||||
|
ws = MagicMock()
|
||||||
|
ws.receive_text.side_effect = [
|
||||||
|
json.dumps({"type": "ping"}),
|
||||||
|
json.dumps({"type": "exit", "exit_code": 0}),
|
||||||
|
]
|
||||||
|
session = PtySession(ws, "cl-abc")
|
||||||
|
events = list(session)
|
||||||
|
assert events[0].type == PtyEventType.ping
|
||||||
|
sent = [json.loads(c[0][0]) for c in ws.send_text.call_args_list]
|
||||||
|
assert {"type": "pong"} in sent
|
||||||
|
|
||||||
|
def test_no_pong_without_ping(self):
|
||||||
|
ws = MagicMock()
|
||||||
|
ws.receive_text.side_effect = [
|
||||||
|
json.dumps({"type": "output", "data": ""}),
|
||||||
|
json.dumps({"type": "exit", "exit_code": 0}),
|
||||||
|
]
|
||||||
|
session = PtySession(ws, "cl-abc")
|
||||||
|
list(session)
|
||||||
|
sent = [json.loads(c[0][0]) for c in ws.send_text.call_args_list]
|
||||||
|
assert {"type": "pong"} not in sent
|
||||||
|
|
||||||
|
def test_send_pong_swallows_closed_ws(self):
|
||||||
|
import httpx_ws
|
||||||
|
|
||||||
|
ws = MagicMock()
|
||||||
|
ws.send_text.side_effect = httpx_ws.WebSocketNetworkError()
|
||||||
|
session = PtySession(ws, "cl-abc")
|
||||||
|
session._send_pong() # must not raise
|
||||||
|
|
||||||
|
|
||||||
class TestPtySessionContextManager:
|
class TestPtySessionContextManager:
|
||||||
def test_exit_kills_and_closes(self):
|
def test_exit_kills_and_closes(self):
|
||||||
ws = MagicMock()
|
ws = MagicMock()
|
||||||
@ -378,7 +411,7 @@ class TestPtySessionSendStart:
|
|||||||
cols=120,
|
cols=120,
|
||||||
rows=40,
|
rows=40,
|
||||||
envs={"TERM": "xterm-256color"},
|
envs={"TERM": "xterm-256color"},
|
||||||
cwd="/home/user",
|
cwd="/home/wrenn-user",
|
||||||
)
|
)
|
||||||
sent = json.loads(ws.send_text.call_args[0][0])
|
sent = json.loads(ws.send_text.call_args[0][0])
|
||||||
assert sent["cmd"] == "/bin/zsh"
|
assert sent["cmd"] == "/bin/zsh"
|
||||||
@ -450,6 +483,28 @@ class TestAsyncPtySession:
|
|||||||
assert sent["cmd"] == "/bin/zsh"
|
assert sent["cmd"] == "/bin/zsh"
|
||||||
assert sent["cols"] == 100
|
assert sent["cols"] == 100
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_ping_triggers_pong(self):
|
||||||
|
ws = AsyncMock()
|
||||||
|
ws.receive_text.side_effect = [
|
||||||
|
json.dumps({"type": "ping"}),
|
||||||
|
json.dumps({"type": "exit", "exit_code": 0}),
|
||||||
|
]
|
||||||
|
session = AsyncPtySession(ws, "cl-abc")
|
||||||
|
events = [e async for e in session]
|
||||||
|
assert events[0].type == PtyEventType.ping
|
||||||
|
sent = [json.loads(c[0][0]) for c in ws.send_text.call_args_list]
|
||||||
|
assert {"type": "pong"} in sent
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_send_pong_swallows_closed_ws(self):
|
||||||
|
import httpx_ws
|
||||||
|
|
||||||
|
ws = AsyncMock()
|
||||||
|
ws.send_text.side_effect = httpx_ws.WebSocketNetworkError()
|
||||||
|
session = AsyncPtySession(ws, "cl-abc")
|
||||||
|
await session._send_pong() # must not raise
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_iteration(self):
|
async def test_async_iteration(self):
|
||||||
ws = AsyncMock()
|
ws = AsyncMock()
|
||||||
|
|||||||
@ -46,7 +46,7 @@ class TestCapsuleLifecycle:
|
|||||||
assert capsule_id
|
assert capsule_id
|
||||||
assert capsule.info is not None
|
assert capsule.info is not None
|
||||||
finally:
|
finally:
|
||||||
capsule.destroy()
|
capsule.destroy(wait=True)
|
||||||
|
|
||||||
info = Capsule.get_info(capsule_id)
|
info = Capsule.get_info(capsule_id)
|
||||||
assert info.status in (Status.stopped, Status.missing)
|
assert info.status in (Status.stopped, Status.missing)
|
||||||
@ -65,7 +65,7 @@ class TestCapsuleLifecycle:
|
|||||||
assert capsule.is_running()
|
assert capsule.is_running()
|
||||||
|
|
||||||
info = Capsule.get_info(capsule_id)
|
info = Capsule.get_info(capsule_id)
|
||||||
assert info.status in (Status.stopped, Status.missing)
|
assert info.status in (Status.stopping, Status.stopped, Status.missing)
|
||||||
|
|
||||||
def test_get_info(self):
|
def test_get_info(self):
|
||||||
capsule = Capsule(wait=True)
|
capsule = Capsule(wait=True)
|
||||||
@ -80,11 +80,11 @@ class TestCapsuleLifecycle:
|
|||||||
def test_pause_and_resume(self):
|
def test_pause_and_resume(self):
|
||||||
capsule = Capsule(wait=True)
|
capsule = Capsule(wait=True)
|
||||||
try:
|
try:
|
||||||
paused = capsule.pause()
|
paused = capsule.pause(wait=True)
|
||||||
assert paused.status == Status.paused
|
assert paused.status == Status.paused
|
||||||
assert not capsule.is_running()
|
assert not capsule.is_running()
|
||||||
|
|
||||||
resumed = capsule.resume()
|
resumed = capsule.resume(wait=True)
|
||||||
assert resumed.status == Status.running
|
assert resumed.status == Status.running
|
||||||
finally:
|
finally:
|
||||||
capsule.destroy()
|
capsule.destroy()
|
||||||
@ -93,7 +93,7 @@ class TestCapsuleLifecycle:
|
|||||||
capsule = Capsule(wait=True)
|
capsule = Capsule(wait=True)
|
||||||
capsule_id = capsule.capsule_id
|
capsule_id = capsule.capsule_id
|
||||||
try:
|
try:
|
||||||
Capsule.destroy(capsule_id)
|
Capsule.destroy(capsule_id, wait=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
capsule.destroy()
|
capsule.destroy()
|
||||||
raise
|
raise
|
||||||
@ -218,11 +218,14 @@ class TestCommands:
|
|||||||
def test_kill_process(self):
|
def test_kill_process(self):
|
||||||
handle = self.capsule.commands.run("sleep 30", background=True)
|
handle = self.capsule.commands.run("sleep 30", background=True)
|
||||||
self.capsule.commands.kill(handle.pid)
|
self.capsule.commands.kill(handle.pid)
|
||||||
time.sleep(0.5)
|
# Registry prune runs asynchronously after the process end event,
|
||||||
|
# so poll rather than asserting on a zero-delay list().
|
||||||
processes = self.capsule.commands.list()
|
deadline = time.monotonic() + 5
|
||||||
pids = [p.pid for p in processes]
|
while time.monotonic() < deadline:
|
||||||
assert handle.pid not in pids
|
if handle.pid not in [p.pid for p in self.capsule.commands.list()]:
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
assert handle.pid not in [p.pid for p in self.capsule.commands.list()]
|
||||||
|
|
||||||
def test_run_duration_ms(self):
|
def test_run_duration_ms(self):
|
||||||
result = self.capsule.commands.run("sleep 1")
|
result = self.capsule.commands.run("sleep 1")
|
||||||
@ -320,7 +323,7 @@ class TestFiles:
|
|||||||
class TestGit:
|
class TestGit:
|
||||||
"""Shared capsule for git operation tests.
|
"""Shared capsule for git operation tests.
|
||||||
|
|
||||||
Initializes a repo at /root (default cwd) since the exec API
|
Initializes a repo at /home/wrenn-user (default cwd) since the exec API
|
||||||
does not support the cwd parameter.
|
does not support the cwd parameter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -341,14 +344,14 @@ class TestGit:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def test_init_created_repo(self):
|
def test_init_created_repo(self):
|
||||||
assert self.capsule.files.exists("/root/.git")
|
assert self.capsule.files.exists("/home/wrenn-user/.git")
|
||||||
|
|
||||||
def test_status_clean(self):
|
def test_status_clean(self):
|
||||||
status = self.capsule.git.status()
|
status = self.capsule.git.status()
|
||||||
assert status.branch == "main"
|
assert status.branch == "main"
|
||||||
|
|
||||||
def test_add_and_commit(self):
|
def test_add_and_commit(self):
|
||||||
self.capsule.files.write("/root/hello.txt", "hello git")
|
self.capsule.files.write("/home/wrenn-user/hello.txt", "hello git")
|
||||||
self.capsule.git.add(all=True)
|
self.capsule.git.add(all=True)
|
||||||
result = self.capsule.git.commit("initial commit")
|
result = self.capsule.git.commit("initial commit")
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@ -358,14 +361,14 @@ class TestGit:
|
|||||||
assert status.is_clean
|
assert status.is_clean
|
||||||
|
|
||||||
def test_status_with_changes(self):
|
def test_status_with_changes(self):
|
||||||
self.capsule.files.write("/root/dirty.txt", "uncommitted")
|
self.capsule.files.write("/home/wrenn-user/dirty.txt", "uncommitted")
|
||||||
try:
|
try:
|
||||||
status = self.capsule.git.status()
|
status = self.capsule.git.status()
|
||||||
assert not status.is_clean
|
assert not status.is_clean
|
||||||
paths = [f.path for f in status.files]
|
paths = [f.path for f in status.files]
|
||||||
assert "dirty.txt" in paths
|
assert "dirty.txt" in paths
|
||||||
finally:
|
finally:
|
||||||
self.capsule.files.remove("/root/dirty.txt")
|
self.capsule.files.remove("/home/wrenn-user/dirty.txt")
|
||||||
|
|
||||||
def test_branches(self):
|
def test_branches(self):
|
||||||
branches = self.capsule.git.branches()
|
branches = self.capsule.git.branches()
|
||||||
|
|||||||
533
tests/test_integration_advanced.py
Normal file
533
tests/test_integration_advanced.py
Normal file
@ -0,0 +1,533 @@
|
|||||||
|
"""Advanced integration tests against a live Wrenn server.
|
||||||
|
|
||||||
|
Skipped automatically when ``WRENN_API_KEY`` is not set (see conftest.py).
|
||||||
|
|
||||||
|
Covers working-directory / environment handling, long-running commands
|
||||||
|
(``apt-get``), interactive PTY sessions, streaming exec, and real ``git``
|
||||||
|
workflows including cloning ``github.com/wrennhq/wrenn``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wrenn import Capsule
|
||||||
|
from wrenn.commands import StreamExitEvent, StreamStartEvent
|
||||||
|
from wrenn.exceptions import WrennError
|
||||||
|
from wrenn.pty import PtyEventType
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
WRENN_REPO = "https://github.com/wrennhq/wrenn"
|
||||||
|
|
||||||
|
_env_loaded = False
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_env() -> None:
|
||||||
|
global _env_loaded
|
||||||
|
if _env_loaded:
|
||||||
|
return
|
||||||
|
_env_loaded = True
|
||||||
|
env_file = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
if not env_file.exists():
|
||||||
|
return
|
||||||
|
for line in env_file.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key, value = key.strip(), value.strip().strip("\"'")
|
||||||
|
if key and key not in os.environ:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Working directory & environment
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandEnvironment:
|
||||||
|
"""cwd / envs handling for foreground commands."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_cwd_changes_working_directory(self):
|
||||||
|
result = self.capsule.commands.run("pwd", cwd="/tmp")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "/tmp"
|
||||||
|
|
||||||
|
def test_default_cwd_is_home(self):
|
||||||
|
result = self.capsule.commands.run("pwd")
|
||||||
|
assert result.stdout.strip() == "/home/wrenn-user"
|
||||||
|
|
||||||
|
def test_cwd_resolves_relative_paths(self):
|
||||||
|
self.capsule.files.make_dir("/tmp/cwd_probe/sub")
|
||||||
|
result = self.capsule.commands.run("ls", cwd="/tmp/cwd_probe")
|
||||||
|
assert "sub" in result.stdout
|
||||||
|
|
||||||
|
def test_cwd_nonexistent_raises(self):
|
||||||
|
with pytest.raises(WrennError):
|
||||||
|
self.capsule.commands.run("pwd", cwd="/no/such/dir/xyz")
|
||||||
|
|
||||||
|
def test_cwd_does_not_persist_between_calls(self):
|
||||||
|
# Each run is a fresh process — `cd` in one does not affect the next.
|
||||||
|
self.capsule.commands.run("cd /tmp")
|
||||||
|
result = self.capsule.commands.run("pwd")
|
||||||
|
assert result.stdout.strip() == "/home/wrenn-user"
|
||||||
|
|
||||||
|
def test_single_env_var(self):
|
||||||
|
result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"})
|
||||||
|
assert result.stdout.strip() == "hi"
|
||||||
|
|
||||||
|
def test_multiple_env_vars(self):
|
||||||
|
result = self.capsule.commands.run(
|
||||||
|
"echo $A-$B-$C", envs={"A": "1", "B": "2", "C": "3"}
|
||||||
|
)
|
||||||
|
assert result.stdout.strip() == "1-2-3"
|
||||||
|
|
||||||
|
def test_env_vars_do_not_leak_between_calls(self):
|
||||||
|
self.capsule.commands.run("echo $SECRET", envs={"SECRET": "leaky"})
|
||||||
|
result = self.capsule.commands.run("echo [$SECRET]")
|
||||||
|
assert result.stdout.strip() == "[]"
|
||||||
|
|
||||||
|
def test_env_var_with_special_chars(self):
|
||||||
|
value = "a b&c|d;e"
|
||||||
|
result = self.capsule.commands.run('printf "%s" "$X"', envs={"X": value})
|
||||||
|
assert result.stdout == value
|
||||||
|
|
||||||
|
def test_base_environment_present(self):
|
||||||
|
result = self.capsule.commands.run("echo $HOME; echo $PATH")
|
||||||
|
lines = result.stdout.strip().splitlines()
|
||||||
|
assert lines[0] == "/home/wrenn-user"
|
||||||
|
assert "/usr/bin" in lines[1]
|
||||||
|
|
||||||
|
def test_sudo_available(self):
|
||||||
|
result = self.capsule.commands.run("which sudo")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_sudo_runs_without_password(self):
|
||||||
|
result = self.capsule.commands.run("sudo whoami")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "root"
|
||||||
|
|
||||||
|
def test_sudo_can_write_to_protected_path(self):
|
||||||
|
result = self.capsule.commands.run(
|
||||||
|
"sudo touch /opt/sudo-test-marker && cat /opt/sudo-test-marker"
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_sudo_can_read_root_owned_file(self):
|
||||||
|
result = self.capsule.commands.run("sudo cat /etc/shadow | head -1")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "root" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Long-running commands
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestLongRunningCommands:
|
||||||
|
"""apt-get installs and other slow commands."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_apt_get_install(self):
|
||||||
|
result = self.capsule.commands.run(
|
||||||
|
"sudo apt-get update -qq && sudo apt-get install -y -qq cowsay", timeout=300
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_apt_installed_binary_runs(self):
|
||||||
|
# Depends on test_apt_get_install having installed the package.
|
||||||
|
self.capsule.commands.run("apt-get install -y -qq cowsay", timeout=300)
|
||||||
|
result = self.capsule.commands.run("/usr/games/cowsay moo")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "moo" in result.stdout
|
||||||
|
|
||||||
|
def test_foreground_timeout_raises(self):
|
||||||
|
# A command exceeding its timeout surfaces as a server-side error.
|
||||||
|
with pytest.raises(WrennError):
|
||||||
|
self.capsule.commands.run("sleep 20", timeout=2)
|
||||||
|
|
||||||
|
def test_long_sleep_in_background_returns_immediately(self):
|
||||||
|
start = time.monotonic()
|
||||||
|
handle = self.capsule.commands.run(
|
||||||
|
"sleep 60", background=True, tag="long-sleep"
|
||||||
|
)
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
assert elapsed < 10
|
||||||
|
assert handle.pid > 0
|
||||||
|
self.capsule.commands.kill(handle.pid)
|
||||||
|
|
||||||
|
def test_slow_command_within_timeout(self):
|
||||||
|
result = self.capsule.commands.run("sleep 3 && echo done", timeout=30)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "done"
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# PTY sessions
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
def _drain_pty(term, *, max_events: int = 200) -> tuple[bytes, int | None]:
|
||||||
|
"""Collect PTY output until exit; return (output, exit_code)."""
|
||||||
|
output = b""
|
||||||
|
exit_code: int | None = None
|
||||||
|
for i, event in enumerate(term):
|
||||||
|
if event.type == PtyEventType.output and event.data:
|
||||||
|
output += event.data
|
||||||
|
elif event.type == PtyEventType.exit:
|
||||||
|
exit_code = event.exit_code
|
||||||
|
break
|
||||||
|
elif event.type == PtyEventType.error and event.fatal:
|
||||||
|
break
|
||||||
|
if i >= max_events:
|
||||||
|
break
|
||||||
|
return output, exit_code
|
||||||
|
|
||||||
|
|
||||||
|
class TestPty:
|
||||||
|
"""Interactive PTY behaviour."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_pty_runs_command_and_exits(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash") as term:
|
||||||
|
term.write(b"echo pty-result-$((6*7))\n")
|
||||||
|
term.write(b"exit\n")
|
||||||
|
output, exit_code = _drain_pty(term)
|
||||||
|
assert b"pty-result-42" in output
|
||||||
|
assert exit_code is not None
|
||||||
|
|
||||||
|
def test_pty_started_event_sets_tag_and_pid(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash") as term:
|
||||||
|
term.write(b"exit\n")
|
||||||
|
_drain_pty(term)
|
||||||
|
assert term.tag is not None
|
||||||
|
assert term.tag.startswith("pty-")
|
||||||
|
assert term.pid is not None and term.pid > 0
|
||||||
|
|
||||||
|
def test_pty_respects_cwd(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash", cwd="/tmp") as term:
|
||||||
|
term.write(b"pwd\n")
|
||||||
|
term.write(b"exit\n")
|
||||||
|
output, _ = _drain_pty(term)
|
||||||
|
assert b"/tmp" in output
|
||||||
|
|
||||||
|
def test_pty_respects_envs(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash", envs={"PTY_VAR": "xyzzy"}) as term:
|
||||||
|
term.write(b"echo marker-$PTY_VAR\n")
|
||||||
|
term.write(b"exit\n")
|
||||||
|
output, _ = _drain_pty(term)
|
||||||
|
assert b"marker-xyzzy" in output
|
||||||
|
|
||||||
|
def test_pty_resize(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash", cols=80, rows=24) as term:
|
||||||
|
term.resize(120, 40)
|
||||||
|
term.write(b"echo resized\n")
|
||||||
|
term.write(b"exit\n")
|
||||||
|
output, _ = _drain_pty(term)
|
||||||
|
assert b"resized" in output
|
||||||
|
|
||||||
|
def test_pty_explicit_command(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/echo", args=["hello-from-argv"]) as term:
|
||||||
|
output, exit_code = _drain_pty(term)
|
||||||
|
assert b"hello-from-argv" in output
|
||||||
|
|
||||||
|
def test_pty_exit_code_nonzero(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash") as term:
|
||||||
|
term.write(b"exit 3\n")
|
||||||
|
_, exit_code = _drain_pty(term)
|
||||||
|
assert exit_code == 3
|
||||||
|
|
||||||
|
def test_pty_survives_idle_ping_cycle(self):
|
||||||
|
# The server emits a keepalive `ping` (~every 30s); the SDK must
|
||||||
|
# auto-reply `pong` and the session must stay usable afterwards.
|
||||||
|
with self.capsule.pty(cmd="/bin/bash") as term:
|
||||||
|
saw_ping = False
|
||||||
|
for event in term:
|
||||||
|
if event.type == PtyEventType.ping:
|
||||||
|
saw_ping = True
|
||||||
|
break
|
||||||
|
if event.type == PtyEventType.exit:
|
||||||
|
break
|
||||||
|
if event.type == PtyEventType.error and event.fatal:
|
||||||
|
break
|
||||||
|
assert saw_ping, "no keepalive ping received"
|
||||||
|
term.write(b"echo still-alive\n")
|
||||||
|
term.write(b"exit\n")
|
||||||
|
output, _ = _drain_pty(term)
|
||||||
|
assert b"still-alive" in output
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Streaming exec
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamingExec:
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_stream_emits_start_and_exit(self):
|
||||||
|
events = list(self.capsule.commands.stream("echo streamed"))
|
||||||
|
types = [e.type for e in events]
|
||||||
|
assert "exit" in types
|
||||||
|
starts = [e for e in events if isinstance(e, StreamStartEvent)]
|
||||||
|
exits = [e for e in events if isinstance(e, StreamExitEvent)]
|
||||||
|
assert exits and exits[0].exit_code == 0
|
||||||
|
if starts:
|
||||||
|
assert starts[0].pid > 0
|
||||||
|
|
||||||
|
def test_stream_captures_stdout(self):
|
||||||
|
events = list(self.capsule.commands.stream("for i in 1 2 3; do echo n$i; done"))
|
||||||
|
out = "".join(
|
||||||
|
e.data for e in events if e.type == "stdout" and getattr(e, "data", None)
|
||||||
|
)
|
||||||
|
assert "n1" in out and "n3" in out
|
||||||
|
|
||||||
|
def test_stream_nonzero_exit(self):
|
||||||
|
events = list(self.capsule.commands.stream("exit 5"))
|
||||||
|
exits = [e for e in events if isinstance(e, StreamExitEvent)]
|
||||||
|
assert exits and exits[0].exit_code == 5
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Process connect — attach to a background process over WebSocket
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestProcessConnect:
|
||||||
|
"""commands.connect — must survive the server's abrupt WebSocket close."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_connect_streams_running_process(self):
|
||||||
|
handle = self.capsule.commands.run(
|
||||||
|
"for i in $(seq 1 5); do echo tick$i; sleep 1; done",
|
||||||
|
background=True,
|
||||||
|
tag="connect-run",
|
||||||
|
)
|
||||||
|
time.sleep(0.3)
|
||||||
|
events = list(self.capsule.commands.connect(handle.pid))
|
||||||
|
types = [e.type for e in events]
|
||||||
|
assert "exit" in types
|
||||||
|
# connect streams output from the attach point onward, so early
|
||||||
|
# ticks may be missed — assert it captured the live tail.
|
||||||
|
out = "".join(
|
||||||
|
e.data for e in events if e.type == "stdout" and getattr(e, "data", None)
|
||||||
|
)
|
||||||
|
assert "tick" in out
|
||||||
|
|
||||||
|
def test_connect_to_finished_process_does_not_raise(self):
|
||||||
|
handle = self.capsule.commands.run("echo quick", background=True)
|
||||||
|
time.sleep(2)
|
||||||
|
# Process already exited — server closes the WebSocket abruptly;
|
||||||
|
# the iterator must terminate cleanly rather than raise.
|
||||||
|
events = list(self.capsule.commands.connect(handle.pid))
|
||||||
|
assert isinstance(events, list)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Git — real workflows including cloning wrennhq/wrenn
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitClone:
|
||||||
|
"""Clone github.com/wrennhq/wrenn and operate on it."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
cls.capsule.git.clone(
|
||||||
|
WRENN_REPO, "/home/wrenn-user/wrenn", depth=1, timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_clone_created_repo(self):
|
||||||
|
assert self.capsule.files.exists("/home/wrenn-user/wrenn/.git")
|
||||||
|
|
||||||
|
def test_clone_checked_out_files(self):
|
||||||
|
entries = self.capsule.files.list("/home/wrenn-user/wrenn")
|
||||||
|
names = [e.name for e in entries]
|
||||||
|
assert "README.md" in names
|
||||||
|
|
||||||
|
def test_status_of_clone_is_clean(self):
|
||||||
|
status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
|
||||||
|
assert status.branch == "main"
|
||||||
|
assert status.is_clean
|
||||||
|
|
||||||
|
def test_branches_lists_main(self):
|
||||||
|
branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn")
|
||||||
|
names = [b.name for b in branches]
|
||||||
|
assert "main" in names
|
||||||
|
assert any(b.is_current for b in branches)
|
||||||
|
|
||||||
|
def test_remote_get_origin(self):
|
||||||
|
url = self.capsule.git.remote_get("origin", cwd="/home/wrenn-user/wrenn")
|
||||||
|
assert url is not None
|
||||||
|
assert "wrennhq/wrenn" in url
|
||||||
|
|
||||||
|
def test_git_log_has_commit(self):
|
||||||
|
result = self.capsule.commands.run(
|
||||||
|
"git log --oneline -1", cwd="/home/wrenn-user/wrenn"
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip()
|
||||||
|
|
||||||
|
def test_modify_add_commit(self):
|
||||||
|
marker = uuid.uuid4().hex
|
||||||
|
self.capsule.git.configure_user(
|
||||||
|
"CI Bot", "ci@example.com", cwd="/home/wrenn-user/wrenn", scope="local"
|
||||||
|
)
|
||||||
|
self.capsule.files.write(
|
||||||
|
f"/home/wrenn-user/wrenn/sdk_probe_{marker}.txt", marker
|
||||||
|
)
|
||||||
|
self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/home/wrenn-user/wrenn")
|
||||||
|
|
||||||
|
staged = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
|
||||||
|
assert staged.has_staged
|
||||||
|
|
||||||
|
result = self.capsule.git.commit("probe commit", cwd="/home/wrenn-user/wrenn")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
after = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
|
||||||
|
assert after.is_clean
|
||||||
|
assert after.ahead >= 1
|
||||||
|
|
||||||
|
def test_create_and_checkout_branch_in_clone(self):
|
||||||
|
self.capsule.git.create_branch("sdk-feature", cwd="/home/wrenn-user/wrenn")
|
||||||
|
branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn")
|
||||||
|
current = [b for b in branches if b.is_current]
|
||||||
|
assert current and current[0].name == "sdk-feature"
|
||||||
|
self.capsule.git.checkout_branch("main", cwd="/home/wrenn-user/wrenn")
|
||||||
|
|
||||||
|
def test_diff_via_commands(self):
|
||||||
|
self.capsule.files.write("/home/wrenn-user/wrenn/README.md", "overwritten\n")
|
||||||
|
try:
|
||||||
|
result = self.capsule.commands.run(
|
||||||
|
"git diff --stat", cwd="/home/wrenn-user/wrenn"
|
||||||
|
)
|
||||||
|
assert "README.md" in result.stdout
|
||||||
|
finally:
|
||||||
|
self.capsule.git.restore(
|
||||||
|
["README.md"], worktree=True, cwd="/home/wrenn-user/wrenn"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitErrors:
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_clone_nonexistent_repo_raises(self):
|
||||||
|
from wrenn._git import GitError
|
||||||
|
|
||||||
|
with pytest.raises(GitError):
|
||||||
|
self.capsule.git.clone(
|
||||||
|
"https://github.com/wrennhq/this-repo-does-not-exist-xyz",
|
||||||
|
"/home/wrenn-user/missing",
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_status_outside_repo_raises(self):
|
||||||
|
from wrenn._git import GitError
|
||||||
|
|
||||||
|
with pytest.raises(GitError):
|
||||||
|
self.capsule.git.status(cwd="/tmp")
|
||||||
|
|
||||||
|
def test_clone_with_branch(self):
|
||||||
|
self.capsule.git.clone(
|
||||||
|
WRENN_REPO,
|
||||||
|
"/home/wrenn-user/wrenn-main",
|
||||||
|
branch="main",
|
||||||
|
depth=1,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn-main")
|
||||||
|
assert status.branch == "main"
|
||||||
411
uv.lock
generated
411
uv.lock
generated
@ -2,7 +2,8 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.14'",
|
"python_full_version >= '3.15'",
|
||||||
|
"python_full_version == '3.14.*'",
|
||||||
"python_full_version < '3.14'",
|
"python_full_version < '3.14'",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -36,9 +37,49 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" },
|
{ url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ast-serialize"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "black"
|
name = "black"
|
||||||
version = "26.3.1"
|
version = "26.5.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
@ -48,28 +89,28 @@ dependencies = [
|
|||||||
{ name = "platformdirs" },
|
{ name = "platformdirs" },
|
||||||
{ name = "pytokens" },
|
{ name = "pytokens" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" },
|
{ url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" },
|
{ url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" },
|
{ url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" },
|
{ url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.2.25"
|
version = "2026.5.20"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
|
||||||
wheels = [
|
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/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -140,14 +181,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.3.2"
|
version = "8.4.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
|
{ url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -201,7 +242,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "datamodel-code-generator"
|
name = "datamodel-code-generator"
|
||||||
version = "0.56.0"
|
version = "0.57.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "argcomplete" },
|
{ name = "argcomplete" },
|
||||||
@ -213,9 +254,9 @@ dependencies = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/03/7d/7fc2bb3d8946ca45851da3f23497a2c6e252e92558ccbd89d609cf1e13d4/datamodel_code_generator-0.56.0.tar.gz", hash = "sha256:e7c003fb5421b890aabe12f66ae65b57198b04cfe1da7c40810798020835b3a8", size = 837708, upload-time = "2026-04-04T09:46:19.636Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/5d/44/87d5980f813a1e323c5d726b3ac5fec8c915ce8a77fcdceaf9c00457dbae/datamodel_code_generator-0.57.0.tar.gz", hash = "sha256:0eda778ea06eaa476e542a5f1fe1d14cc3bbf686edb33a0ad6151c7d19089906", size = 932941, upload-time = "2026-05-07T16:21:55.819Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/3a/7f169ffc7a2d69a4f9158b1ac083f685b7f4a1a8a1db5d1e4abbb4e741b7/datamodel_code_generator-0.56.0-py3-none-any.whl", hash = "sha256:a0559683fbe90cdf2ce9b6637e3adae3e3a8056a8d0516df581d486e2834ead2", size = 256545, upload-time = "2026-04-04T09:46:17.582Z" },
|
{ url = "https://files.pythonhosted.org/packages/c5/c1/4fb9a44bb4a305b860c5a5b1866dcccfac3b76f5f170a9e68fc7733e16d2/datamodel_code_generator-0.57.0-py3-none-any.whl", hash = "sha256:d26bf5defe5154493d0aa5a822b7725332b9e9dd2abccc2f8856052286aa83b5", size = 259343, upload-time = "2026-05-07T16:21:53.823Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@ -381,11 +422,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.11"
|
version = "3.15"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -433,49 +474,49 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "librt"
|
name = "librt"
|
||||||
version = "0.8.1"
|
version = "0.11.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" },
|
{ url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" },
|
{ url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" },
|
{ url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" },
|
{ url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" },
|
{ url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" },
|
{ url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" },
|
{ url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" },
|
{ url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" },
|
{ url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" },
|
{ url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" },
|
{ url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" },
|
{ url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" },
|
{ url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" },
|
{ url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" },
|
{ url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -532,47 +573,48 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "more-itertools"
|
name = "more-itertools"
|
||||||
version = "11.0.1"
|
version = "11.0.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/24/24/e0acc4bf54cba50c1d432c70a72a3df96db4a321b2c4c68432a60759044f/more_itertools-11.0.1.tar.gz", hash = "sha256:fefaf25b7ab08f0b45fa9f1892cae93b9fc0089ef034d39213bce15f1cc9e199", size = 144739, upload-time = "2026-04-02T16:17:45.061Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/f4/5e52c7319b8087acef603ed6e50dc325c02eaa999355414830468611f13c/more_itertools-11.0.1-py3-none-any.whl", hash = "sha256:eaf287826069452a8f61026c597eae2428b2d1ba2859083abbf240b46842ce6d", size = 72182, upload-time = "2026-04-02T16:17:43.724Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mypy"
|
name = "mypy"
|
||||||
version = "1.20.0"
|
version = "2.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "ast-serialize" },
|
||||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
{ name = "mypy-extensions" },
|
{ name = "mypy-extensions" },
|
||||||
{ name = "pathspec" },
|
{ name = "pathspec" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" },
|
{ url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" },
|
{ url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" },
|
{ url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" },
|
{ url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" },
|
{ url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" },
|
{ url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" },
|
{ url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" },
|
{ url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -626,20 +668,20 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.0"
|
version = "26.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pathspec"
|
name = "pathspec"
|
||||||
version = "1.0.4"
|
version = "1.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -678,7 +720,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.12.5"
|
version = "2.13.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-types" },
|
{ name = "annotated-types" },
|
||||||
@ -686,62 +728,65 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-core"
|
name = "pydantic-core"
|
||||||
version = "2.41.5"
|
version = "2.46.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -808,15 +853,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-discovery"
|
name = "python-discovery"
|
||||||
version = "1.2.2"
|
version = "1.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "filelock" },
|
{ name = "filelock" },
|
||||||
{ name = "platformdirs" },
|
{ 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" }
|
sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" }
|
||||||
wheels = [
|
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" },
|
{ url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -881,7 +926,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.33.1"
|
version = "2.34.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
@ -889,9 +934,9 @@ dependencies = [
|
|||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -908,27 +953,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.10"
|
version = "0.15.13"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" },
|
{ url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" },
|
{ url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" },
|
{ url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" },
|
{ url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" },
|
{ url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
|
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -990,14 +1035,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typeguard"
|
name = "typeguard"
|
||||||
version = "4.5.1"
|
version = "4.5.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/e8/66e25efcc18542d58706ce4e50415710593721aae26e794ab1dec34fb66f/typeguard-4.5.1.tar.gz", hash = "sha256:f6f8ecbbc819c9bc749983cc67c02391e16a9b43b8b27f15dc70ed7c4a007274", size = 80121, upload-time = "2026-02-19T16:09:03.392Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/67/1c/dfba5c4633cafc4c701f237d2ba63b416805047fd6d96aab4cfc40969f98/typeguard-4.5.2.tar.gz", hash = "sha256:5a16dcac23502039299c97c8941651bc33d7ea8cc4b2f7d6bbb1b528f6eea423", size = 80240, upload-time = "2026-05-14T12:59:40.857Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/88/b55b3117287a8540b76dbdd87733808d4d01c8067a3b339408c250bb3600/typeguard-4.5.1-py3-none-any.whl", hash = "sha256:44d2bf329d49a244110a090b55f5f91aa82d9a9834ebfd30bcc73651e4a8cc40", size = 36745, upload-time = "2026-02-19T16:09:01.6Z" },
|
{ url = "https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl", hash = "sha256:fcf9de18bd945cdb4c7b996e12b4c51ce83f92f191314a6d7cf1739586ec98cf", size = 36748, upload-time = "2026-05-14T12:59:39.473Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1023,16 +1068,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.6.3"
|
version = "2.7.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||||
wheels = [
|
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/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "virtualenv"
|
name = "virtualenv"
|
||||||
version = "21.3.0"
|
version = "21.3.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "distlib" },
|
{ name = "distlib" },
|
||||||
@ -1040,9 +1085,9 @@ dependencies = [
|
|||||||
{ name = "platformdirs" },
|
{ name = "platformdirs" },
|
||||||
{ name = "python-discovery" },
|
{ 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" }
|
sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" }
|
||||||
wheels = [
|
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" },
|
{ url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1121,9 +1166,10 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wrenn"
|
name = "wrenn"
|
||||||
version = "0.1.1"
|
version = "0.2.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
{ name = "email-validator" },
|
{ name = "email-validator" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "httpx-ws" },
|
{ name = "httpx-ws" },
|
||||||
@ -1144,6 +1190,7 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "certifi", specifier = ">=2026.2.25" },
|
||||||
{ name = "email-validator", specifier = ">=2.3.0" },
|
{ name = "email-validator", specifier = ">=2.3.0" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "httpx-ws", specifier = ">=0.9.0" },
|
{ name = "httpx-ws", specifier = ">=0.9.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user