47 Commits

Author SHA1 Message Date
fbcedc5317 Merge pull request 'Added more tests, fixed bugs and updated pipeline' (#11) from feat/test-code-interpreter into dev
Some checks failed
ci/woodpecker/push/unit Pipeline was successful
ci/woodpecker/pr/unit Pipeline was successful
ci/woodpecker/pr/integration Pipeline was canceled
ci/woodpecker/pr/code-runner Pipeline was canceled
Reviewed-on: #11
2026-05-20 00:26:22 +00:00
005871441a ci: split Woodpecker pipelines by scope
Some checks failed
ci/woodpecker/push/unit Pipeline was successful
ci/woodpecker/pr/unit Pipeline was successful
ci/woodpecker/pr/code-runner Pipeline was canceled
ci/woodpecker/pr/integration Pipeline was canceled
- unit.yml: unit tests on every push and pull_request, all branches.
- code-runner.yml: PR to dev/main, gated on src/wrenn/code_runner/**
  or tests/test_code_runner_*.py; runs `make test-code-runner`.
- integration.yml: PR to dev/main, gated on src/** excluding
  src/wrenn/code_runner/**; runs `make test-integration`.

E2E pipelines require a src/** change, so docs/test-only PRs only
trigger the unit pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 05:25:19 +06:00
b2ec7f9ab3 refactor: extract jupyter protocol, harden error paths, dedup git ops
- code_runner: split shared Jupyter message/URL helpers into
  `_protocol.py`; surface kernel disconnects and run_code timeouts as
  ExecutionError; add gif and plotly MIME types to Result.
- capsule: introduce `_build_http_proxy_url` so HTTP proxy callers
  stop munging ws:// URLs; `proxy_url()` now returns http(s).
- _git: collapse `_run` + `_check_result` into `_run_op` across sync
  and async Git; drop unused `build_has_upstream`.
- pty: classify unknown msg_types as non-fatal error events instead
  of raising ValueError.
- files: add `Transfer-Encoding: chunked` to streaming uploads.
- ci: remove unused Woodpecker check.yml.
- tests: expand unit coverage for code_runner and capsule features.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 05:23:38 +06:00
9edde7bff5 feat(code_runner): rename module, fix __del__ + kernel name, expand tests
- Rename `wrenn.code_interpreter` → `wrenn.code_runner` (canonical).
  Keep old path as deprecation alias that emits a FutureWarning on
  import, mirroring the existing `Sandbox` → `Capsule` pattern.
  Submodule shims `code_interpreter/{capsule,async_capsule,models}.py`
  keep direct-submodule imports working.

- Fix sync/async ctor-failure-safe `__del__`: initialise `_kernel_id`,
  `_kernel_name`, `_proxy_client` before calling `super().__init__` so
  a failed creation no longer crashes the destructor with
  AttributeError.

- Send the kernel name to Jupyter. Previously `POST /api/kernels` had
  no body, so the server picked an arbitrary default kernelspec. Now
  sends `{"name": "wrenn"}` (override via `Capsule(kernel=...)`) and
  reuses an existing kernel only when its `name` matches.

- Preserve Jupyter `text/plain` verbatim in `Result.from_bundle`.
  The previous outer-quote strip was lossy (the string `'2'` became
  indistinguishable from the int `2`, and strings containing escaped
  quotes were mangled). `text` is now the `repr()` Jupyter sends.
  Updated the stale `test_capsule_features` quote-strip test.

- Validate `run_code(language=...)`. Anything other than `"python"`
  now raises `ValueError` instead of being silently ignored.

- Async `__del__` no longer touches the event loop; users must call
  `await close()` or use `async with`.

- New unit suite `tests/test_code_runner_unit.py` (46 tests): MIME
  unpacking, deprecation alias + warning, default template + kernel,
  custom kernel override, ctor-failure-safe __del__, kernel
  create/reuse/cache, retry on 5xx, 4xx propagation, request shape,
  run_code stream/result/error/foreign-parent/idle/unsupported-language,
  async variants.

- New e2e suite `tests/test_code_runner_e2e.py` (44 tests, integration
  marker): template == `code-runner-beta`, kernel == `wrenn`, stdout
  /stderr capture, state/import/function/class persistence, exceptions
  (Value/Name/Syntax), callbacks, multi-line, `text` repr preservation,
  filesystem round-trip, isolation between capsules, deprecated import
  path. MIME-type class covers html, markdown, json, latex, svg,
  javascript, png (matplotlib + seaborn), jpeg, multi-format bundles,
  and text-round-trip via numpy + requests.

- `make test-code-runner` runs unit + e2e together. `make test`
  extended to include the unit file.

- README: "Code Interpreter" section renamed to "Code Runner", all
  imports updated, `kernel=` documented, removed the incorrect
  "quotes stripped automatically" claim, replaced with the actual
  `text/plain` semantics.

- CLAUDE.md: appended a "Code Runner Module" section covering module
  path, defaults, kernel-reuse semantics, lifecycle invariant, and
  the new test files + make target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 04:29:31 +06:00
369c75af24 ci: run unit tests on every push
All checks were successful
ci/woodpecker/push/check Pipeline was successful
ci/woodpecker/pr/check Pipeline was successful
ci/woodpecker/pull_request_metadata/check Pipeline was successful
Move per-step `when` filters: unit tests now run on every branch push,
integration tests keep pull_request + main/dev branch restriction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:19:20 +06:00
41ee41e9cd Merge pull request 'fix: update SDK for v0.2.0 API compatibility' (#10) from fix/0.2-compatibility into dev
Some checks failed
ci/woodpecker/pr/check Pipeline failed
Reviewed-on: #10
2026-05-19 11:16:20 +00:00
fce514c49c test: expand command/PTY/git coverage, fix WebSocket close handling
Some checks failed
ci/woodpecker/pr/check Pipeline failed
Tests:
- tests/test_commands.py: unit coverage for Commands/AsyncCommands —
  payload construction (cwd, envs, tag, timeout), background dispatch,
  base64 response decoding, stream-event parsing, stream/connect iterators.
- tests/test_integration_advanced.py: live tests for cwd/env handling,
  long-running commands (apt-get), PTY sessions, streaming exec,
  process connect, and git workflows including cloning wrennhq/wrenn.
- test_filesystem_pty.py: PTY ping/pong reply tests.
- test_integration.py: poll for async process-registry prune in
  test_kill_process instead of asserting on a zero-delay list().

Fixes:
- commands.py / pty.py: stream(), connect() and the PTY iterators only
  caught WebSocketDisconnect. The server closes exec/process streams
  abruptly, raising WebSocketNetworkError — a sibling under
  HTTPXWSException — which crashed connect() entirely. Both are now
  caught via _WS_CLOSED so abrupt closes end iteration cleanly.
- pty.py: reply to the server keepalive ping with a pong so idle PTY
  sessions stay open.
2026-05-19 17:12:52 +06:00
87cc16e9e2 chore: merge origin/dev, bump version to 0.1.4
Resolve conflicts in api/openapi.yaml and src/wrenn/models/_generated.py
by keeping the fix/0.2-compatibility versions (v0.2 API is authoritative).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:25:22 +06:00
08f6a1ab84 Merge branch 'main' of git.omukk.dev:wrenn/python-sdk into dev
All checks were successful
ci/woodpecker/pr/check Pipeline was successful
2026-05-19 15:22:46 +06:00
51c6987515 fix: sync SDK with v0.2 API, add wait kwargs to lifecycle ops
- Drop AuthResponse from models __init__ (renamed SessionResponse server-side; SDK auths via API key, doesn't need either)
- Regenerate models from updated 0.2 openapi spec
- Add wait: bool = False kwarg to Capsule/AsyncCapsule destroy/pause/resume (instance + _static_*); 500ms poll for resume/destroy, 2s for pause
- Unify polling into _poll_until / _apoll_until + _wait_for_status helper; remove duplicated _POLL_INTERVALS tables
- wait_ready: drop implicit paused->resume side effect; treat missing as fail
- Capsule.connect: handle transient pausing (wait for paused first) before resuming, fixes hang when caller pauses then connects immediately
- Drop dead "if self._id is None" branch in Capsule.__init__ after assigning from already-truthy _capsule_id
- files.make_dir: detect already_exists across 409/wrapped error messages via shared _is_already_exists helper
- tests/test_integration.py: assertions on final lifecycle state use wait=True
2026-05-19 15:06:49 +06:00
e057ec2407 Merge branch 'main' into dev
All checks were successful
ci/woodpecker/pr/check Pipeline was successful
2026-05-19 07:10:17 +00:00
e5e4e1a85b fix: update SDK for v0.2.0 API compatibility
Some checks failed
ci/woodpecker/pr/check Pipeline failed
Sync OpenAPI spec to v0.2.0, fix type annotation shadowing by using
builtins.list in annotated signatures, guard poll interval lookup
against None status, and reorder capsule ID assignment to validate
before storing.
2026-05-16 17:57:20 +06:00
6112c71abc test: make process kill integration test resilient
Some checks failed
ci/woodpecker/pr/check Pipeline failed
2026-05-16 17:02:25 +06:00
d9c028564e Merge branch 'bugfix/timeout-related-issues' into dev
Some checks failed
ci/woodpecker/pr/check Pipeline failed
2026-05-02 21:53:33 +06:00
06b4a8cbcb Merge issues fixed
All checks were successful
ci/woodpecker/pr/check Pipeline was successful
2026-05-02 21:46:16 +06:00
04e5dc652f Fix error handling, resource leaks, and logic bugs across the SDK
Bugs fixed:
- files.py: use typed error checking (_raise_for_status) instead of raw
  raise_for_status(), ensuring WrennNotFoundError etc. are raised
  correctly
- exceptions.py: check both "capsule_ids" and "sandbox_ids" response
  keys
  for backwards compatibility
- code_interpreter: retry _ensure_kernel on 5xx errors (only fail on
  4xx),
  remove redundant TimeoutError in bare except, clean up non-standard
  top-level msg_id/msg_type from Jupyter messages

Resource leaks fixed:
- capsule.py: close WrennClient if capsule creation or init fails
- code_interpreter: add close()/__del__ for _proxy_client cleanup when
  not using context manager

Logic fixes:
- pty.py: yield exit events to callers instead of silently discarding
  them
- capsule.py: auto-resume paused capsules in wait_ready instead of
  failing
- capsule.py: log warnings on destroy failure in __exit__ instead of
  silently swallowing errors
2026-05-02 21:34:02 +06:00
4a7db8e204 fix: set httpx read timeout for long-running commands and handle
non-JSON error responses
- Set per-request httpx timeout (command timeout + 10s buffer) in
  Commands.run() and AsyncCommands.run() for foreground exec calls,
  preventing HTTP read timeouts on long-running commands
- Raise WrennInternalError instead of raw httpx.HTTPStatusError when
  handle_response() encounters a non-JSON error body (e.g. 502 from
  a reverse proxy)
2026-05-02 19:02:39 +06:00
a76be96682 Merge branch 'main' of git.omukk.dev:wrenn/python-sdk into dev 2026-05-02 05:07:13 +06:00
dc66ac24d5 Updated woodpecker def
All checks were successful
ci/woodpecker/pr/check Pipeline was successful
2026-05-02 04:50:11 +06:00
b5e2b12ef1 Version bump and other minor changes 2026-05-02 04:45:05 +06:00
213af4aee7 Increased timeout for long running API calls and updated typehints 2026-05-02 04:44:26 +06:00
aa9477ffe8 Added doc generator for SDK
All checks were successful
ci/woodpecker/push/check Pipeline was successful
2026-04-24 00:01:20 +06:00
2bb3dbd71d Merge branch 'main' of git.omukk.dev:wrenn/python-sdk into dev 2026-04-23 23:53:15 +06:00
3f26a2fbcf Merge branch 'main' into dev
Some checks failed
ci/woodpecker/push/check Pipeline was canceled
2026-04-23 12:38:41 +00:00
2faf0dd0ae Updated woodpecker config
All checks were successful
ci/woodpecker/push/check Pipeline was successful
2026-04-23 18:36:35 +06:00
68c7d0de42 ci: add test pipeline, PyPI release workflow, and lint fixes
- Update Woodpecker to run unit and integration tests in parallel
- Add GitHub Actions workflow for PyPI trusted publishing on main
- Add license, classifiers, keywords, and URLs to pyproject.toml
- Fix ruff lint errors (unused imports, duplicate class name) and formatting
2026-04-23 18:32:59 +06:00
ad64c85393 Merge pull request 'Feat: Added git support' (#5) from feat/git-support into dev
Some checks failed
ci/woodpecker/push/check Pipeline failed
Reviewed-on: #5
2026-04-22 23:45:36 +00:00
bab53aedbe Updated readme 2026-04-23 05:44:49 +06:00
82e181dd7e test: add integration tests for capsule lifecycle, commands, files, and git
43 tests across 4 classes hitting the live API. Shared capsule per class
to minimize VM boot overhead. All capsules destroyed in teardown.
Skips automatically when WRENN_API_KEY is not available.
2026-04-23 05:40:06 +06:00
ee1f55635f fix: wrap commands in /bin/sh -c for proper server-side argv expansion
The server-side agent runs commands through a nice wrapper that uses
"${@}" expansion. Sending the full command string as a single cmd field
caused nice to treat it as one executable name. Now Commands.run sends
cmd=/bin/sh args=["-c", cmd_string] so "${@}" expands into proper argv.
2026-04-23 05:16:08 +06:00
6bdf28e2ae Added git integration 2026-04-23 04:46:57 +06:00
61bc040098 Minor patches
Some checks failed
ci/woodpecker/push/check Pipeline failed
2026-04-23 02:31:47 +06:00
7b35ffb60c docs: add Google-style docstrings to all public SDK methods
Some checks failed
ci/woodpecker/push/check Pipeline failed
2026-04-17 04:29:34 +06:00
42bcc792d6 Updated dependency
Some checks failed
ci/woodpecker/push/check Pipeline failed
2026-04-17 03:29:45 +06:00
3f97c73b2f feat: redesign code interpreter with structured Execution model
Some checks failed
ci/woodpecker/push/check Pipeline failed
Replace flat CodeResult with a proper model hierarchy: Execution
(top-level), Result (per-output with typed MIME fields), Logs
(stdout/stderr as lists), and ExecutionError (structured
name/value/traceback). Handle display_data messages for rich output,
add streaming callbacks (on_result, on_stdout, on_stderr, on_error),
and remove the misleading stdout-to-text fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 03:16:39 +06:00
7e7ecbd48a Merge pull request 'feat: implement client architecture and sandbox environment' (#3) from feat/client-and-sandbox-support into dev
Some checks failed
ci/woodpecker/push/check Pipeline failed
Reviewed-on: #3
2026-04-15 15:35:40 +00:00
7b9a06d1b5 chore: add python-dotenv dependency
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:33:53 +06:00
3d0eda5c60 feat: rename kill to destroy, improve code interpreter, update README
- Rename Capsule.kill/AsyncCapsule.kill to destroy for frontend consistency
- Add Sandbox deprecation alias to wrenn.code_interpreter module
- run_code text falls back to stripped stdout when no expression result
- Strip quotes from string expression results (matching e2b behavior)
- _ensure_kernel reuses existing Jupyter kernels before creating new ones
- Rewrite README with complete examples for capsules and code interpreter
- Remove stale AGENTS.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:58:59 +06:00
eecf1dc65b chore: update OpenAPI schema, generated models, and build config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:31:07 +06:00
3cced768a4 feat: redesign SDK with e2b-compatible interface
Replace the WrennClient-centric API with a top-level Capsule class that
mirrors e2b's Sandbox interface, enabling drop-in migration. Key changes:

- Capsule/AsyncCapsule with direct construction (reads WRENN_API_KEY and
  WRENN_BASE_URL env vars), namespaced sub-objects (capsule.commands,
  capsule.files), dual instance/static lifecycle methods via _DualMethod
  descriptor (capsule.kill() and Capsule.kill(id))
- WrennClient simplified to API-key-only endpoints (capsules, snapshots);
  JWT-based resources (auth, hosts, teams) removed
- wrenn.code_interpreter submodule with Capsule subclass defaulting to
  code-runner-beta template and run_code() support
- Sandbox alias emits FutureWarning instead of DeprecationWarning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:19:23 +06:00
0ac9bf79ee feat: created README 2026-04-13 03:16:44 +06:00
bf5914c0a8 fix: renamed sandbox to capsule 2026-04-13 03:16:27 +06:00
976af9a209 ci: woodpecker doesn't support variable expansions outside of commands 2026-04-12 03:08:34 +06:00
f3fd6865f9 ci: bug fixes 2026-04-12 03:03:33 +06:00
340ed46df6 CI for linting and testing 2026-04-12 02:51:14 +06:00
a5bf66c199 feat: add sandbox filesystem and terminal support
Add sandbox filesystem methods (list_dir, mkdir, remove, upload,
download, stream_upload, stream_download) and interactive PTY sessions
(PtySession, AsyncPtySession) with reconnect support per
FILE_TERMINAL.md spec. Refactor error handling into exceptions.py as
shared handle_response(). Replace API-key-only proxy auth with unified
_proxy_headers() supporting both API key and JWT. Fix stream_upload to
build multipart manually instead of relying on httpx files= with
generators. Switch Makefile SPEC_URL from main to dev branch. Regenerate
models from updated OpenAPI spec (adds teams, channels, metrics, PTY
endpoints). Add comprehensive unit and integration tests. Trim AGENTS.md
to verified facts only.
2026-04-12 02:35:20 +06:00
f51a962fff feat: implement client architecture and sandbox environment
Introduces the core Wrenn client and a dedicated sandbox execution
environment. This includes automated model generation and a custom
exception hierarchy to support robust integration.

- Add `WrennClient` in `src/wrenn/client.py` for API interaction.
- Implement `Sandbox` in `src/wrenn/sandbox.py` for isolated execution.
- Add Pydantic/model support via `_generated.py`.
- Define project-specific error types in `exceptions.py`.
- Include AGENTS.md documentation for specialized logic.
- Add comprehensive unit and integration tests.
- Update build system (Makefile, uv.lock, pyproject.toml) and LICENSE.
2026-04-10 22:24:50 +06:00
32 changed files with 4309 additions and 3349 deletions

View File

@ -13,7 +13,7 @@ repos:
- pydantic>=2.12.5
- httpx>=0.28.1
- httpx-ws>=0.9.0
- types-PyYAML
- email-validator>=2.3.0
- repo: local
hooks:

View File

@ -6,8 +6,6 @@ when:
include:
- "src/wrenn/code_runner/**"
- "tests/test_code_runner_*.py"
- "pyproject.toml"
- "uv.lock"
steps:
test-code-runner:

View File

@ -7,12 +7,8 @@ when:
path:
include:
- "src/**"
- "tests/**"
- "pyproject.toml"
- "uv.lock"
exclude:
- "src/wrenn/code_runner/**"
- "tests/test_code_runner_*.py"
steps:
test-integration:

View File

@ -192,29 +192,6 @@ Jupyter kernel.
`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

View File

@ -1,5 +1,5 @@
# Makefile
.PHONY: generate gen-docs lint test check test-integration test-code-runner prune-spec
.PHONY: generate lint test check test-integration test-code-runner
# Variables
SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml"
@ -12,8 +12,6 @@ generate:
curl -fsSL $(SPEC_URL) -o $(SPEC_PATH)
$(MAKE) prune-spec
uv run datamodel-codegen \
--input $(SPEC_PATH) \
--output src/wrenn/models/_generated.py \
@ -35,17 +33,13 @@ test:
uv run pytest tests/test_client.py tests/test_code_runner_unit.py -v
test-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
uv run pytest tests/ -v -m "integration or not integration"
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
prune-spec:
@echo "Pruning spec down to the SDK's API-key surface..."
uv run python scripts/prune_openapi.py $(SPEC_PATH)
gen-docs:
mkdir -p docs
uv run pydoc-markdown > docs/reference.md

View File

@ -26,31 +26,10 @@ Optionally override the API base URL:
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:
```python
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
)
from wrenn import Capsule
capsule = Capsule(api_key="wrn_...", base_url="https://...")
```
@ -172,8 +151,6 @@ import sys
# Stream a new command
for event in capsule.commands.stream("python", args=["-u", "train.py"]):
match event.type:
case "start":
print(f"PID: {event.pid}")
case "stdout":
print(event.data, end="")
case "stderr":
@ -183,11 +160,8 @@ for event in capsule.commands.stream("python", args=["-u", "train.py"]):
# Connect to a running background process
for event in capsule.commands.connect(handle.pid):
match event.type:
case "start":
print(f"PID: {event.pid}")
case "stdout":
print(event.data, end="")
if event.type == "stdout":
print(event.data, end="")
```
#### Process Management
@ -216,7 +190,6 @@ capsule.files.exists("/app/main.py") # True
# List directory
entries = capsule.files.list("/home/user", depth=1)
# FileEntry has: name, type (file/dir), size, modified_at
for entry in entries:
print(entry.name, entry.type, entry.size)
@ -295,27 +268,8 @@ value = capsule.git.get_config("user.name", cwd="/app") # str | None
capsule.git.remote_add("upstream", "https://github.com/org/repo.git", cwd="/app")
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`:
```python
@ -333,7 +287,7 @@ except GitAuthError as e:
```python
import sys
with capsule.pty(cmd="/bin/bash", cols=80, rows=24, cwd="/home/user") as term:
with capsule.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term:
term.write(b"ls -la\n")
for event in term:
if event.type == "output":
@ -476,10 +430,9 @@ result = capsule.run_code("print('running on custom template')")
| `logs` | `Logs` | `.stdout: list[str]` and `.stderr: list[str]` chunks |
| `error` | `ExecutionError \| None` | `.name`, `.value`, `.traceback` |
| `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` |
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`.
Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `pdf`, `latex`, `json`, `javascript`, 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 Runner + Commands/Files
@ -553,15 +506,15 @@ The SDK maps server error codes to typed exceptions:
```python
from wrenn import (
WrennError,
WrennValidationError, # 400
WrennAuthenticationError, # 401
WrennForbiddenError, # 403
WrennNotFoundError, # 404
WrennConflictError, # 409
WrennHostHasCapsulesError, # 409 (host has running capsules)
WrennInternalError, # 500
WrennAgentError, # 502
WrennHostUnavailableError, # 503
WrennValidationError, # 400
WrennAuthenticationError, # 401
WrennForbiddenError, # 403
WrennNotFoundError, # 404
WrennConflictError, # 409
WrennHostHasCapsulesError, # 409 (host has running capsules)
WrennAgentError, # 502
WrennInternalError, # 500
WrennHostUnavailableError, # 503
)
try:
@ -629,7 +582,7 @@ with WrennClient(api_key="wrn_...") as client:
# Snapshots
template = client.snapshots.create(capsule_id="cl-abc", name="my-snap")
templates = client.snapshots.list(type="custom") # optional type filter
templates = client.snapshots.list()
client.snapshots.delete("my-snap")
```

File diff suppressed because it is too large Load Diff

View File

@ -166,131 +166,6 @@ Reset the inactivity timer for a capsule.
- `WrennNotFoundError` - If no capsule with the given ID exists.
<a id="wrenn.client.CapsulesResource.stats"></a>
#### stats
```python
def stats(range: str | None = None) -> CapsuleStats
```
Get aggregate capsule usage stats for the authenticated team.
**Arguments**:
- `range` _str | None_ - Time window. One of ``5m``, ``1h``, ``6h``,
``24h``, ``30d``. Defaults to ``1h`` server-side.
**Returns**:
- `CapsuleStats` - Current running counts plus 30-day peaks and a
chart-ready time series.
Example::
stats = wrenn.capsules.stats(range="24h")
print(stats.current.running_count, stats.peaks.vcpus)
<a id="wrenn.client.CapsulesResource.usage"></a>
#### usage
```python
def usage(from_: str | date | None = None,
to: str | date | None = None) -> UsageResponse
```
Get per-day CPU and RAM usage for the team.
**Arguments**:
- `from_` _str | date | None_ - Start date (``YYYY-MM-DD`` string or
``date``). Defaults to 30 days ago server-side.
- `to` _str | date | None_ - End date. Defaults to today.
**Returns**:
- `UsageResponse` - Daily ``cpu_minutes`` / ``ram_mb_minutes`` points.
Example::
from datetime import date, timedelta
today = date.today()
usage = wrenn.capsules.usage(from_=today - timedelta(days=7), to=today)
for point in usage.points:
print(point.date, point.cpu_minutes, point.ram_mb_minutes)
<a id="wrenn.client.CapsulesResource.metrics"></a>
#### metrics
```python
def metrics(id: str, range: str | None = None) -> CapsuleMetrics
```
Get time-series CPU, memory, and disk metrics for a capsule.
**Arguments**:
- `id` _str_ - Capsule ID.
- `range` _str | None_ - One of ``10m`` (500ms samples),
``2h`` (30s averages), ``24h`` (5-minute averages). Defaults
to ``10m`` server-side.
**Returns**:
- `CapsuleMetrics` - Sampled :class:`MetricPoint` series.
**Raises**:
- `WrennNotFoundError` - If the capsule does not exist or has been
destroyed.
Example::
m = wrenn.capsules.metrics("sb-abc123", range="2h")
for point in m.points:
print(point.timestamp_unix, point.cpu_pct, point.mem_bytes)
<a id="wrenn.client.EventsResource"></a>
## EventsResource Objects
```python
class EventsResource()
```
Sync server-sent event stream of capsule/template/host lifecycle events.
<a id="wrenn.client.EventsResource.stream"></a>
#### stream
```python
def stream() -> Iterator[SSEEvent]
```
Stream lifecycle events for the team in real time.
The connection is held open by the server; iterate the result to
receive :class:`SSEEvent` payloads as they arrive. Close the
iterator (or break out of the loop) to disconnect.
**Yields**:
- `SSEEvent` - One event per server frame.
Example::
with WrennClient() as wrenn:
for ev in wrenn.events.stream():
print(ev.event, ev.resource)
<a id="wrenn.client.AsyncCapsulesResource"></a>
## AsyncCapsulesResource Objects
@ -451,129 +326,6 @@ Reset the inactivity timer for a capsule.
- `WrennNotFoundError` - If no capsule with the given ID exists.
<a id="wrenn.client.AsyncCapsulesResource.stats"></a>
#### stats
```python
async def stats(range: str | None = None) -> CapsuleStats
```
Get aggregate capsule usage stats for the authenticated team.
**Arguments**:
- `range` _str | None_ - Time window. One of ``5m``, ``1h``, ``6h``,
``24h``, ``30d``. Defaults to ``1h`` server-side.
**Returns**:
- `CapsuleStats` - Current running counts plus 30-day peaks and a
chart-ready time series.
Example::
stats = await wrenn.capsules.stats(range="24h")
print(stats.current.running_count, stats.peaks.vcpus)
<a id="wrenn.client.AsyncCapsulesResource.usage"></a>
#### usage
```python
async def usage(from_: str | date | None = None,
to: str | date | None = None) -> UsageResponse
```
Get per-day CPU and RAM usage for the team.
**Arguments**:
- `from_` _str | date | None_ - Start date (``YYYY-MM-DD`` string or
``date``). Defaults to 30 days ago server-side.
- `to` _str | date | None_ - End date. Defaults to today.
**Returns**:
- `UsageResponse` - Daily ``cpu_minutes`` / ``ram_mb_minutes`` points.
Example::
from datetime import date, timedelta
today = date.today()
usage = await wrenn.capsules.usage(
from_=today - timedelta(days=7), to=today
)
for point in usage.points:
print(point.date, point.cpu_minutes, point.ram_mb_minutes)
<a id="wrenn.client.AsyncCapsulesResource.metrics"></a>
#### metrics
```python
async def metrics(id: str, range: str | None = None) -> CapsuleMetrics
```
Get time-series CPU, memory, and disk metrics for a capsule.
**Arguments**:
- `id` _str_ - Capsule ID.
- `range` _str | None_ - One of ``10m`` (500ms samples),
``2h`` (30s averages), ``24h`` (5-minute averages). Defaults
to ``10m`` server-side.
**Returns**:
- `CapsuleMetrics` - Sampled :class:`MetricPoint` series.
**Raises**:
- `WrennNotFoundError` - If the capsule does not exist or has been
destroyed.
Example::
m = await wrenn.capsules.metrics("sb-abc123", range="2h")
for point in m.points:
print(point.timestamp_unix, point.cpu_pct, point.mem_bytes)
<a id="wrenn.client.AsyncEventsResource"></a>
## AsyncEventsResource Objects
```python
class AsyncEventsResource()
```
Async server-sent event stream of capsule/template/host lifecycle events.
<a id="wrenn.client.AsyncEventsResource.stream"></a>
#### stream
```python
async def stream() -> AsyncIterator[SSEEvent]
```
Stream lifecycle events for the team in real time.
**Yields**:
- `SSEEvent` - One event per server frame.
Example::
async with AsyncWrennClient() as wrenn:
async for ev in wrenn.events.stream():
print(ev.event, ev.resource)
<a id="wrenn.client.SnapshotsResource"></a>
## SnapshotsResource Objects
@ -732,31 +484,12 @@ class WrennClient()
Synchronous client for the Wrenn API.
Authenticates with an API key. Exposes three resources:
- :attr:`capsules` — capsule lifecycle, stats, usage, metrics
- :attr:`snapshots` — template snapshot management
- :attr:`events` — server-sent lifecycle event stream
Authenticates with an API key.
**Arguments**:
- `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.
- `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).
Example::
from wrenn import WrennClient
with WrennClient() as wrenn: # reads WRENN_API_KEY
capsule = wrenn.capsules.create(template="minimal-ubuntu")
print(capsule.id, capsule.status)
wrenn.capsules.destroy(capsule.id)
- `base_url` - Wrenn API base URL.
<a id="wrenn.client.WrennClient.http"></a>
@ -789,29 +522,12 @@ class AsyncWrennClient()
Asynchronous client for the Wrenn API.
Authenticates with an API key. Mirrors :class:`WrennClient` with
``await``-able methods on every resource.
Authenticates with an API key.
**Arguments**:
- `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.
- `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).
Example::
from wrenn import AsyncWrennClient
async with AsyncWrennClient() as wrenn:
capsule = await wrenn.capsules.create(template="minimal-ubuntu")
async for event in wrenn.events.stream():
if event.resource and event.resource.id == capsule.id:
break
<a id="wrenn.client.AsyncWrennClient.http"></a>
@ -2208,6 +1924,18 @@ Send SIGKILL to the PTY process.
# wrenn.models.\_generated
<a id="wrenn.models._generated.SessionResponse"></a>
## SessionResponse Objects
```python
class SessionResponse(BaseModel)
```
Returned by login, activate, and switch-team. The actual auth credential
is the wrenn_sid cookie set on the response. The body carries identity
data the SPA needs to bootstrap.
<a id="wrenn.models._generated.Peaks"></a>
## Peaks Objects
@ -2238,6 +1966,16 @@ class Encoding(StrEnum)
Output encoding. "base64" when stdout/stderr contain binary data.
<a id="wrenn.models._generated.Type2"></a>
## Type2 Objects
```python
class Type2(StrEnum)
```
Host type. Regular hosts are shared; BYOC hosts belong to a team.
<a id="wrenn.models._generated.Outcome"></a>
## Outcome Objects
@ -2886,15 +2624,10 @@ async def run_code(
Execute code in a persistent Jupyter kernel (async).
Variables, imports, and function definitions survive across calls.
**Arguments**:
- `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.
- `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.
@ -3087,42 +2820,12 @@ Build a Jupyter ``execute_request`` message envelope.
expected to read ``msg["header"]["msg_id"]`` to correlate
responses.
<a id="wrenn.code_runner._protocol.pick_kernel_id"></a>
#### pick\_kernel\_id
```python
def pick_kernel_id(kernels: list[dict], kernel_name: str) -> str | None
```
Return the ID of the first kernel matching ``kernel_name``, else ``None``.
<a id="wrenn.code_runner._protocol.apply_kernel_message"></a>
#### apply\_kernel\_message
```python
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.
<a id="wrenn.code_runner._protocol.build_ws_url"></a>
#### build\_ws\_url
```python
def build_ws_url(base_url: str,
capsule_id: str,
kernel_id: str,
proxy_domain: str | None = None) -> str
def build_ws_url(base_url: str, capsule_id: str, kernel_id: str) -> str
```
Build the Jupyter kernel WebSocket URL for the given capsule.

View File

@ -1,6 +1,6 @@
[project]
name = "wrenn"
version = "0.3.0"
version = "0.1.4"
description = "Python SDK for Wrenn"
readme = "README.md"
license = "MIT"
@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"certifi>=2026.2.25",
"email-validator>=2.3.0",
"httpx>=0.28.1",
"httpx-ws>=0.9.0",
"pydantic>=2.12.5",
@ -40,7 +40,6 @@ dev = [
"pydoc-markdown>=4.8.2",
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
"pyyaml>=6.0.3",
"respx>=0.23.1",
"ruff>=0.15.10",
]

View File

@ -1,179 +0,0 @@
"""Prune the upstream Wrenn OpenAPI spec down to the SDK's surface.
The upstream spec at ``wrennhq/wrenn/internal/api/openapi.yaml`` covers
the whole control plane: account/team/host/channel/admin routes that
require an interactive ``wrenn_sid`` cookie. The Python SDK only ever
authenticates with an API key, so those routes are dead surface area
here. This script keeps only paths that list ``apiKeyAuth`` (and the
schemas they transitively reach) and rewrites the spec in place.
Run via ``make generate`` (curl ➜ prune ➜ datamodel-codegen) or
``uv run python scripts/prune_openapi.py [path]`` directly.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import yaml
KEEP_PATHS: set[str] = {
"/v1/capsules",
"/v1/capsules/stats",
"/v1/capsules/usage",
"/v1/capsules/{id}",
"/v1/capsules/{id}/exec",
"/v1/capsules/{id}/exec/stream",
"/v1/capsules/{id}/processes",
"/v1/capsules/{id}/processes/{selector}",
"/v1/capsules/{id}/processes/{selector}/stream",
"/v1/capsules/{id}/ping",
"/v1/capsules/{id}/metrics",
"/v1/capsules/{id}/pause",
"/v1/capsules/{id}/resume",
"/v1/capsules/{id}/files/write",
"/v1/capsules/{id}/files/read",
"/v1/capsules/{id}/files/list",
"/v1/capsules/{id}/files/mkdir",
"/v1/capsules/{id}/files/remove",
"/v1/capsules/{id}/files/stream/write",
"/v1/capsules/{id}/files/stream/read",
"/v1/capsules/{id}/pty",
"/v1/snapshots",
"/v1/snapshots/{name}",
"/v1/events/stream",
}
KEEP_SECURITY_SCHEMES: set[str] = {"apiKeyAuth"}
REF_RE = re.compile(r"#/components/(schemas|responses)/([A-Za-z0-9_]+)")
def _walk_refs(node: object, schemas: dict, responses: dict, seen: set[str]) -> None:
"""Depth-first walk collecting ``schemas:Name`` / ``responses:Name`` refs."""
if isinstance(node, dict):
for k, v in node.items():
if k == "$ref" and isinstance(v, str):
m = REF_RE.search(v)
if m:
kind, name = m.group(1), m.group(2)
key = f"{kind}:{name}"
if key in seen:
continue
seen.add(key)
target = (schemas if kind == "schemas" else responses).get(name)
if target is not None:
_walk_refs(target, schemas, responses, seen)
else:
_walk_refs(v, schemas, responses, seen)
elif isinstance(node, list):
for item in node:
_walk_refs(item, schemas, responses, seen)
def _filter_security(operation: dict) -> None:
sec = operation.get("security")
if not isinstance(sec, list):
return
kept = [
entry
for entry in sec
if isinstance(entry, dict)
and any(name in KEEP_SECURITY_SCHEMES for name in entry)
]
if kept:
operation["security"] = kept
else:
operation.pop("security", None)
def prune(spec: dict) -> dict:
paths = spec.get("paths", {})
components = spec.get("components", {})
schemas = components.get("schemas", {}) or {}
responses = components.get("responses", {}) or {}
missing = KEEP_PATHS - set(paths)
if missing:
raise SystemExit(
"prune_openapi: upstream spec is missing expected paths: "
+ ", ".join(sorted(missing))
)
kept_paths: dict[str, dict] = {}
for path, item in paths.items():
if path not in KEEP_PATHS:
continue
if not isinstance(item, dict):
kept_paths[path] = item
continue
for method, op in list(item.items()):
if isinstance(op, dict):
_filter_security(op)
if "security" in op and not op["security"]:
del item[method]
kept_paths[path] = item
seen: set[str] = set()
_walk_refs(kept_paths, schemas, responses, seen)
# Walk again to close over inter-schema refs we just pulled in.
while True:
before = len(seen)
for key in list(seen):
kind, name = key.split(":", 1)
target = (schemas if kind == "schemas" else responses).get(name)
if target is not None:
_walk_refs(target, schemas, responses, seen)
if len(seen) == before:
break
kept_schemas = {n: schemas[n] for n in schemas if f"schemas:{n}" in seen}
kept_responses = {n: responses[n] for n in responses if f"responses:{n}" in seen}
sec_schemes = components.get("securitySchemes", {}) or {}
kept_sec_schemes = {
n: sec_schemes[n] for n in sec_schemes if n in KEEP_SECURITY_SCHEMES
}
new_components: dict = {}
if kept_responses:
new_components["responses"] = kept_responses
if kept_sec_schemes:
new_components["securitySchemes"] = kept_sec_schemes
if kept_schemas:
new_components["schemas"] = kept_schemas
spec["paths"] = kept_paths
spec["components"] = new_components
top_security = spec.get("security")
if isinstance(top_security, list):
spec["security"] = [
e
for e in top_security
if isinstance(e, dict) and any(name in KEEP_SECURITY_SCHEMES for name in e)
]
return spec
def main(argv: list[str]) -> int:
target = Path(argv[1]) if len(argv) > 1 else Path("api/openapi.yaml")
spec = yaml.safe_load(target.read_text())
pruned = prune(spec)
target.write_text(
yaml.safe_dump(pruned, sort_keys=False, width=120, allow_unicode=True)
)
print(
f"prune_openapi: kept {len(pruned['paths'])} paths, "
f"{len(pruned['components'].get('schemas', {}))} schemas in {target}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

View File

@ -37,7 +37,7 @@ from wrenn.exceptions import (
from wrenn.models import FileEntry
from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession
__version__ = "0.3.0"
__version__ = "0.1.4"
__all__ = [
"__version__",

View File

@ -1,7 +1,5 @@
from __future__ import annotations
DEFAULT_BASE_URL = "https://app.wrenn.dev/api"
DEFAULT_PROXY_DOMAIN = "wrenn.dev"
ENV_API_KEY = "WRENN_API_KEY"
ENV_BASE_URL = "WRENN_BASE_URL"
ENV_PROXY_DOMAIN = "WRENN_PROXY_DOMAIN"

View File

@ -137,26 +137,21 @@ class AsyncCapsule:
AsyncCapsule: A new capsule instance.
"""
client = AsyncWrennClient(api_key=api_key, base_url=base_url)
try:
info = await client.capsules.create(
template=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(
_capsule_id=info.id,
_client=client,
_info=info,
)
if wait:
await capsule.wait_ready()
return capsule
except BaseException:
await client.aclose()
raise
info = await client.capsules.create(
template=template,
vcpus=vcpus,
memory_mb=memory_mb,
timeout_sec=timeout,
)
assert info.id is not None
capsule = cls(
_capsule_id=info.id,
_client=client,
_info=info,
)
if wait:
await capsule.wait_ready()
return capsule
@classmethod
async def connect(
@ -181,26 +176,22 @@ class AsyncCapsule:
WrennNotFoundError: If no capsule with the given ID exists.
"""
client = AsyncWrennClient(api_key=api_key, base_url=base_url)
try:
info = await client.capsules.get(capsule_id)
info = await client.capsules.get(capsule_id)
capsule = cls(
_capsule_id=capsule_id,
_client=client,
_info=info,
)
capsule = cls(
_capsule_id=capsule_id,
_client=client,
_info=info,
)
if info.status == Status.pausing:
info = await capsule._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
if info.status == Status.paused:
await client.capsules.resume(capsule_id)
if info.status != Status.running:
await capsule.wait_ready()
if info.status == Status.pausing:
info = await capsule._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
if info.status == Status.paused:
await client.capsules.resume(capsule_id)
if info.status != Status.running:
await capsule.wait_ready()
return capsule
except BaseException:
await client.aclose()
raise
return capsule
# ── Dual instance/static lifecycle ──────────────────────────
@ -443,12 +434,7 @@ class AsyncCapsule:
WebSocket access, see the lower-level ``_build_proxy_url``
helper or the ``pty()`` API.
"""
return _build_http_proxy_url(
self._client._base_url,
self._id,
port,
self._client._proxy_domain,
)
return _build_http_proxy_url(self._client._base_url, self._id, port)
# ── Snapshots ───────────────────────────────────────────────

View File

@ -20,47 +20,29 @@ from wrenn.models import Status, Template
from wrenn.pty import PtySession
def _proxy_url(
base_url: str,
capsule_id: str | None,
port: int,
proxy_domain: str | None,
*,
websocket: bool,
) -> str:
def _build_proxy_url(base_url: str, capsule_id: str | None, port: int) -> str:
"""Build the WebSocket proxy URL (``ws://`` / ``wss://``)."""
parsed = httpx.URL(base_url)
if proxy_domain:
host = proxy_domain
else:
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"
host = parsed.host
if parsed.port:
host = f"{host}:{parsed.port}"
scheme = "ws" if parsed.scheme == "http" else "wss"
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) -> str:
"""Build the HTTP proxy URL (``http://`` / ``https://``).
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)
The capsule's API base URL typically carries an ``/api`` path suffix
(e.g. ``https://app.wrenn.dev/api``). The proxy host is derived from
the URL's host only — any path is discarded.
"""
parsed = httpx.URL(base_url)
host = parsed.host
if parsed.port:
host = f"{host}:{parsed.port}"
scheme = "http" if parsed.scheme in ("http", "ws") else "https"
return f"{scheme}://{port}-{capsule_id}.{host}"
_RESUME_INTERVAL = 0.5
@ -544,12 +526,7 @@ class Capsule:
WebSocket access, see the lower-level ``_build_proxy_url``
helper or the ``pty()`` API.
"""
return _build_http_proxy_url(
self._client._base_url,
self._id,
port,
self._client._proxy_domain,
)
return _build_http_proxy_url(self._client._base_url, self._id, port)
# ── Snapshots ───────────────────────────────────────────────

View File

@ -1,84 +1,20 @@
from __future__ import annotations
import asyncio
import os
import time
from collections.abc import AsyncIterator, Iterator
from datetime import date
import httpx
from wrenn._config import (
DEFAULT_BASE_URL,
DEFAULT_PROXY_DOMAIN,
ENV_API_KEY,
ENV_BASE_URL,
ENV_PROXY_DOMAIN,
)
from wrenn.exceptions import _raise_for_status, handle_response
from wrenn._config import DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL
from wrenn.exceptions import handle_response
from wrenn.models import (
CapsuleMetrics,
CapsuleStats,
SSEEvent,
Template,
UsageResponse,
)
from wrenn.models import (
Capsule as CapsuleModel,
)
_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:
@ -90,113 +26,6 @@ def _resolve_api_key(api_key: str | None) -> str:
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
def _date_param(value: str | date | None) -> str | None:
if value is None:
return None
if isinstance(value, date):
return value.isoformat()
return value
def _usage_params(from_: str | date | None, to: str | date | None) -> dict:
params: dict = {}
if (v := _date_param(from_)) is not None:
params["from"] = v
if (v := _date_param(to)) is not None:
params["to"] = v
return params
def _range_params(range: str | None) -> dict:
return {"range": range} if range is not None else {}
def _iter_sse_events(lines: Iterator[str]) -> Iterator[SSEEvent]:
"""Parse SSE ``data:`` frames into :class:`SSEEvent` objects.
Ignores ``event:`` names and ``:keepalive`` comments — the payload's
own ``event`` field carries the kind.
"""
data_lines: list[str] = []
for raw in lines:
if raw == "":
if data_lines:
yield SSEEvent.model_validate_json("\n".join(data_lines))
data_lines = []
continue
if raw.startswith(":"):
continue
if raw.startswith("data:"):
data_lines.append(raw[5:].lstrip())
class CapsulesResource:
"""Sync capsule control-plane operations."""
@ -222,10 +51,16 @@ class CapsulesResource:
Returns:
CapsuleModel: The newly created capsule.
"""
resp = self._http.post(
"/v1/capsules",
json=_build_capsule_create_payload(template, vcpus, memory_mb, timeout_sec),
)
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
resp = self._http.post("/v1/capsules", json=payload)
return CapsuleModel.model_validate(handle_response(resp))
def list(self) -> list[CapsuleModel]:
@ -306,106 +141,6 @@ class CapsulesResource:
resp = self._http.post(f"/v1/capsules/{id}/ping")
handle_response(resp)
def stats(self, range: str | None = None) -> CapsuleStats:
"""Get aggregate capsule usage stats for the authenticated team.
Args:
range (str | None): Time window. One of ``5m``, ``1h``, ``6h``,
``24h``, ``30d``. Defaults to ``1h`` server-side.
Returns:
CapsuleStats: Current running counts plus 30-day peaks and a
chart-ready time series.
Example::
stats = wrenn.capsules.stats(range="24h")
print(stats.current.running_count, stats.peaks.vcpus)
"""
resp = self._http.get("/v1/capsules/stats", params=_range_params(range))
return CapsuleStats.model_validate(handle_response(resp))
def usage(
self,
from_: str | date | None = None,
to: str | date | None = None,
) -> UsageResponse:
"""Get per-day CPU and RAM usage for the team.
Args:
from_ (str | date | None): Start date (``YYYY-MM-DD`` string or
``date``). Defaults to 30 days ago server-side.
to (str | date | None): End date. Defaults to today.
Returns:
UsageResponse: Daily ``cpu_minutes`` / ``ram_mb_minutes`` points.
Example::
from datetime import date, timedelta
today = date.today()
usage = wrenn.capsules.usage(from_=today - timedelta(days=7), to=today)
for point in usage.points:
print(point.date, point.cpu_minutes, point.ram_mb_minutes)
"""
resp = self._http.get("/v1/capsules/usage", params=_usage_params(from_, to))
return UsageResponse.model_validate(handle_response(resp))
def metrics(self, id: str, range: str | None = None) -> CapsuleMetrics:
"""Get time-series CPU, memory, and disk metrics for a capsule.
Args:
id (str): Capsule ID.
range (str | None): One of ``10m`` (500ms samples),
``2h`` (30s averages), ``24h`` (5-minute averages). Defaults
to ``10m`` server-side.
Returns:
CapsuleMetrics: Sampled :class:`MetricPoint` series.
Raises:
WrennNotFoundError: If the capsule does not exist or has been
destroyed.
Example::
m = wrenn.capsules.metrics("sb-abc123", range="2h")
for point in m.points:
print(point.timestamp_unix, point.cpu_pct, point.mem_bytes)
"""
resp = self._http.get(f"/v1/capsules/{id}/metrics", params=_range_params(range))
return CapsuleMetrics.model_validate(handle_response(resp))
class EventsResource:
"""Sync server-sent event stream of capsule/template/host lifecycle events."""
def __init__(self, http: httpx.Client) -> None:
self._http = http
def stream(self) -> Iterator[SSEEvent]:
"""Stream lifecycle events for the team in real time.
The connection is held open by the server; iterate the result to
receive :class:`SSEEvent` payloads as they arrive. Close the
iterator (or break out of the loop) to disconnect.
Yields:
SSEEvent: One event per server frame.
Example::
with WrennClient() as wrenn:
for ev in wrenn.events.stream():
print(ev.event, ev.resource)
"""
with self._http.stream("GET", "/v1/events/stream", timeout=None) as resp:
if resp.status_code >= 400:
resp.read()
_raise_for_status(resp)
yield from _iter_sse_events(resp.iter_lines())
class AsyncCapsulesResource:
"""Async capsule control-plane operations."""
@ -432,10 +167,16 @@ class AsyncCapsulesResource:
Returns:
CapsuleModel: The newly created capsule.
"""
resp = await self._http.post(
"/v1/capsules",
json=_build_capsule_create_payload(template, vcpus, memory_mb, timeout_sec),
)
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
resp = await self._http.post("/v1/capsules", json=payload)
return CapsuleModel.model_validate(handle_response(resp))
async def list(self) -> list[CapsuleModel]:
@ -516,118 +257,6 @@ class AsyncCapsulesResource:
resp = await self._http.post(f"/v1/capsules/{id}/ping")
handle_response(resp)
async def stats(self, range: str | None = None) -> CapsuleStats:
"""Get aggregate capsule usage stats for the authenticated team.
Args:
range (str | None): Time window. One of ``5m``, ``1h``, ``6h``,
``24h``, ``30d``. Defaults to ``1h`` server-side.
Returns:
CapsuleStats: Current running counts plus 30-day peaks and a
chart-ready time series.
Example::
stats = await wrenn.capsules.stats(range="24h")
print(stats.current.running_count, stats.peaks.vcpus)
"""
resp = await self._http.get("/v1/capsules/stats", params=_range_params(range))
return CapsuleStats.model_validate(handle_response(resp))
async def usage(
self,
from_: str | date | None = None,
to: str | date | None = None,
) -> UsageResponse:
"""Get per-day CPU and RAM usage for the team.
Args:
from_ (str | date | None): Start date (``YYYY-MM-DD`` string or
``date``). Defaults to 30 days ago server-side.
to (str | date | None): End date. Defaults to today.
Returns:
UsageResponse: Daily ``cpu_minutes`` / ``ram_mb_minutes`` points.
Example::
from datetime import date, timedelta
today = date.today()
usage = await wrenn.capsules.usage(
from_=today - timedelta(days=7), to=today
)
for point in usage.points:
print(point.date, point.cpu_minutes, point.ram_mb_minutes)
"""
resp = await self._http.get(
"/v1/capsules/usage", params=_usage_params(from_, to)
)
return UsageResponse.model_validate(handle_response(resp))
async def metrics(self, id: str, range: str | None = None) -> CapsuleMetrics:
"""Get time-series CPU, memory, and disk metrics for a capsule.
Args:
id (str): Capsule ID.
range (str | None): One of ``10m`` (500ms samples),
``2h`` (30s averages), ``24h`` (5-minute averages). Defaults
to ``10m`` server-side.
Returns:
CapsuleMetrics: Sampled :class:`MetricPoint` series.
Raises:
WrennNotFoundError: If the capsule does not exist or has been
destroyed.
Example::
m = await wrenn.capsules.metrics("sb-abc123", range="2h")
for point in m.points:
print(point.timestamp_unix, point.cpu_pct, point.mem_bytes)
"""
resp = await self._http.get(
f"/v1/capsules/{id}/metrics", params=_range_params(range)
)
return CapsuleMetrics.model_validate(handle_response(resp))
class AsyncEventsResource:
"""Async server-sent event stream of capsule/template/host lifecycle events."""
def __init__(self, http: httpx.AsyncClient) -> None:
self._http = http
async def stream(self) -> AsyncIterator[SSEEvent]:
"""Stream lifecycle events for the team in real time.
Yields:
SSEEvent: One event per server frame.
Example::
async with AsyncWrennClient() as wrenn:
async for ev in wrenn.events.stream():
print(ev.event, ev.resource)
"""
async with self._http.stream("GET", "/v1/events/stream", timeout=None) as resp:
if resp.status_code >= 400:
await resp.aread()
_raise_for_status(resp)
data_lines: list[str] = []
async for raw in resp.aiter_lines():
if raw == "":
if data_lines:
yield SSEEvent.model_validate_json("\n".join(data_lines))
data_lines = []
continue
if raw.startswith(":"):
continue
if raw.startswith("data:"):
data_lines.append(raw[5:].lstrip())
class SnapshotsResource:
"""Sync snapshot operations."""
@ -653,7 +282,12 @@ class SnapshotsResource:
Returns:
Template: The created snapshot template.
"""
payload, params = _build_snapshot_create(capsule_id, name, overwrite)
payload: dict = {"sandbox_id": capsule_id}
if name is not None:
payload["name"] = name
params: dict = {}
if overwrite:
params["overwrite"] = "true"
resp = self._http.post(
"/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT
)
@ -669,7 +303,10 @@ class SnapshotsResource:
Returns:
list[Template]: Matching snapshot templates.
"""
resp = self._http.get("/v1/snapshots", params=_snapshot_list_params(type))
params: dict = {}
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)]
def delete(self, name: str) -> None:
@ -709,7 +346,12 @@ class AsyncSnapshotsResource:
Returns:
Template: The created snapshot template.
"""
payload, params = _build_snapshot_create(capsule_id, name, overwrite)
payload: dict = {"sandbox_id": capsule_id}
if name is not None:
payload["name"] = name
params: dict = {}
if overwrite:
params["overwrite"] = "true"
resp = await self._http.post(
"/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT
)
@ -725,7 +367,10 @@ class AsyncSnapshotsResource:
Returns:
list[Template]: Matching snapshot templates.
"""
resp = await self._http.get("/v1/snapshots", params=_snapshot_list_params(type))
params: dict = {}
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)]
async def delete(self, name: str) -> None:
@ -744,51 +389,27 @@ class AsyncSnapshotsResource:
class WrennClient:
"""Synchronous client for the Wrenn API.
Authenticates with an API key. Exposes three resources:
- :attr:`capsules` — capsule lifecycle, stats, usage, metrics
- :attr:`snapshots` — template snapshot management
- :attr:`events` — server-sent lifecycle event stream
Authenticates with an API key.
Args:
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.
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).
Example::
from wrenn import WrennClient
with WrennClient() as wrenn: # reads WRENN_API_KEY
capsule = wrenn.capsules.create(template="minimal-ubuntu")
print(capsule.id, capsule.status)
wrenn.capsules.destroy(capsule.id)
base_url: Wrenn API base URL.
"""
def __init__(
self,
api_key: str | None = None,
base_url: str | None = None,
proxy_domain: str | None = None,
timeout: httpx.Timeout | float | None = None,
) -> None:
self._api_key = _resolve_api_key(api_key)
self._base_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL)
self._proxy_domain = _resolve_proxy_domain(self._base_url, proxy_domain)
self._http = _RetryingClient(
self._http = httpx.Client(
base_url=self._base_url,
headers={"X-API-Key": self._api_key},
timeout=_resolve_timeout(timeout),
)
self.capsules = CapsulesResource(self._http)
self.snapshots = SnapshotsResource(self._http)
self.events = EventsResource(self._http)
@property
def http(self) -> httpx.Client:
@ -814,49 +435,27 @@ class WrennClient:
class AsyncWrennClient:
"""Asynchronous client for the Wrenn API.
Authenticates with an API key. Mirrors :class:`WrennClient` with
``await``-able methods on every resource.
Authenticates with an API key.
Args:
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.
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).
Example::
from wrenn import AsyncWrennClient
async with AsyncWrennClient() as wrenn:
capsule = await wrenn.capsules.create(template="minimal-ubuntu")
async for event in wrenn.events.stream():
if event.resource and event.resource.id == capsule.id:
break
"""
def __init__(
self,
api_key: str | None = None,
base_url: str | None = None,
proxy_domain: str | None = None,
timeout: httpx.Timeout | float | None = None,
) -> None:
self._api_key = _resolve_api_key(api_key)
self._base_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL)
self._proxy_domain = _resolve_proxy_domain(self._base_url, proxy_domain)
self._http = _RetryingAsyncClient(
self._http = httpx.AsyncClient(
base_url=self._base_url,
headers={"X-API-Key": self._api_key},
timeout=_resolve_timeout(timeout),
)
self.capsules = AsyncCapsulesResource(self._http)
self.snapshots = AsyncSnapshotsResource(self._http)
self.events = AsyncEventsResource(self._http)
@property
def http(self) -> httpx.AsyncClient:

View File

@ -7,15 +7,8 @@ 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:
@ -52,82 +45,7 @@ def build_execute_request(code: str) -> dict:
}
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:
def build_ws_url(base_url: str, capsule_id: str, kernel_id: str) -> str:
"""Build the Jupyter kernel WebSocket URL for the given capsule."""
proxy = _build_proxy_url(base_url, capsule_id, 8888, proxy_domain)
proxy = _build_proxy_url(base_url, capsule_id, 8888)
return f"{proxy}/api/kernels/{kernel_id}/channels"

View File

@ -12,13 +12,7 @@ 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._protocol import build_execute_request, build_ws_url
from wrenn.code_runner.capsule import DEFAULT_KERNEL, DEFAULT_TEMPLATE
from wrenn.code_runner.models import (
Execution,
@ -42,8 +36,6 @@ class AsyncCapsule(BaseAsyncCapsule):
_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
@ -51,45 +43,9 @@ class AsyncCapsule(BaseAsyncCapsule):
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:
@ -103,13 +59,6 @@ class AsyncCapsule(BaseAsyncCapsule):
# 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(
@ -143,36 +92,25 @@ class AsyncCapsule(BaseAsyncCapsule):
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
info = await client.capsules.create(
template=template or DEFAULT_TEMPLATE,
vcpus=vcpus,
memory_mb=memory_mb,
timeout_sec=timeout,
)
capsule = cls(
kernel=kernel,
_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_http_proxy_url(
self._client._base_url,
self._id,
8888,
self._client._proxy_domain,
)
url = _build_http_proxy_url(self._client._base_url, self._id, 8888)
self._proxy_client = httpx.AsyncClient(
base_url=url,
headers={"X-API-Key": self._client._api_key},
@ -192,10 +130,11 @@ class AsyncCapsule(BaseAsyncCapsule):
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
kernels = resp.json()
for k in kernels:
if k.get("name") == self._kernel_name:
self._kernel_id = k["id"]
return self._kernel_id
resp = await client.post(
"/api/kernels",
json={"name": self._kernel_name},
@ -234,14 +173,9 @@ class AsyncCapsule(BaseAsyncCapsule):
) -> 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.
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.
@ -255,14 +189,21 @@ class AsyncCapsule(BaseAsyncCapsule):
An :class:`Execution` with ``.results``, ``.logs``, ``.error``,
and a convenience ``.text`` property.
"""
validate_language(language)
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."
)
kernel_id = await self._ensure_kernel(jupyter_timeout=jupyter_timeout)
ws_url = build_ws_url(self._client._base_url, self._id, kernel_id)
msg = build_execute_request(code)
msg_id = msg["header"]["msg_id"]
execution = Execution()
deadline = time.monotonic() + timeout
headers = {"X-API-Key": self._client._api_key}
saw_idle = False
def _emit_error(err: ExecutionError) -> None:
@ -270,53 +211,69 @@ class AsyncCapsule(BaseAsyncCapsule):
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
async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.AsyncWebSocketSession
await ws.send_text(json.dumps(msg))
while True:
time_left = deadline - time.monotonic()
if time_left <= 0:
break
try:
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}",
except (asyncio.TimeoutError, TimeoutError):
break
except (
httpx_ws.WebSocketDisconnect,
httpx_ws.WebSocketNetworkError,
) as exc:
execution.timed_out = True
_emit_error(
ExecutionError(
name="Disconnected",
value=f"kernel WebSocket closed: {exc}",
)
)
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"
)
execution.timed_out = True
break
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":
saw_idle = True
break
if not saw_idle and execution.error is None:
execution.timed_out = True

View File

@ -10,13 +10,7 @@ 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._protocol import build_execute_request, build_ws_url
from wrenn.code_runner.models import (
Execution,
ExecutionError,
@ -43,8 +37,6 @@ class Capsule(BaseCapsule):
_kernel_id: str | None
_kernel_name: str
_proxy_client: httpx.Client | None
_ws: httpx_ws.WebSocketSession | None
_ws_cm: Any
def __init__(
self,
@ -77,8 +69,6 @@ class Capsule(BaseCapsule):
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,
@ -89,41 +79,7 @@ class Capsule(BaseCapsule):
**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:
@ -138,13 +94,6 @@ class Capsule(BaseCapsule):
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,
@ -189,12 +138,7 @@ class Capsule(BaseCapsule):
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,
)
url = _build_http_proxy_url(self._client._base_url, self._id, 8888)
self._proxy_client = httpx.Client(
base_url=url,
headers={"X-API-Key": self._client._api_key},
@ -215,10 +159,11 @@ class Capsule(BaseCapsule):
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
kernels = resp.json()
for k in kernels:
if k.get("name") == self._kernel_name:
self._kernel_id = k["id"]
return self._kernel_id
# No matching kernel; create one with the requested spec.
resp = client.post(
"/api/kernels",
@ -279,14 +224,21 @@ class Capsule(BaseCapsule):
An :class:`Execution` with ``.results``, ``.logs``, ``.error``,
and a convenience ``.text`` property.
"""
validate_language(language)
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."
)
kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout)
ws_url = build_ws_url(self._client._base_url, self._id, kernel_id)
msg = build_execute_request(code)
msg_id = msg["header"]["msg_id"]
execution = Execution()
deadline = time.monotonic() + timeout
headers = {"X-API-Key": self._client._api_key}
saw_idle = False
def _emit_error(err: ExecutionError) -> None:
@ -294,53 +246,69 @@ class Capsule(BaseCapsule):
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
with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.WebSocketSession
ws.send_text(json.dumps(msg))
while True:
time_left = deadline - time.monotonic()
if time_left <= 0:
break
try:
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}",
except TimeoutError:
break
except (
httpx_ws.WebSocketDisconnect,
httpx_ws.WebSocketNetworkError,
) as exc:
execution.timed_out = True
_emit_error(
ExecutionError(
name="Disconnected",
value=f"kernel WebSocket closed: {exc}",
)
)
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"
)
execution.timed_out = True
break
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":
saw_idle = True
break
if not saw_idle and execution.error is None:
execution.timed_out = True

View File

@ -111,54 +111,6 @@ def _parse_stream_event(raw: dict) -> StreamEvent:
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:
stdout = data.get("stdout") or ""
stderr = data.get("stderr") or ""
@ -237,14 +189,39 @@ class Commands:
CommandHandle: PID and tag for background commands
(``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(
f"/v1/capsules/{self._capsule_id}/exec",
json=_build_exec_payload(cmd, background, timeout, envs, cwd, tag),
timeout=_exec_http_timeout(background, timeout),
json=payload,
timeout=http_timeout,
)
data = handle_response(resp)
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]:
"""List all running background processes in the capsule.
@ -322,7 +299,11 @@ class Commands:
f"/v1/capsules/{self._capsule_id}/exec/stream",
self._http,
) as ws: # type: httpx_ws.WebSocketSession
ws.send_text(json.dumps(_build_stream_start(cmd, args)))
if 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:
try:
raw = ws.receive_json()
@ -397,14 +378,39 @@ class AsyncCommands:
CommandHandle: PID and tag for background commands
(``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(
f"/v1/capsules/{self._capsule_id}/exec",
json=_build_exec_payload(cmd, background, timeout, envs, cwd, tag),
timeout=_exec_http_timeout(background, timeout),
json=payload,
timeout=http_timeout,
)
data = handle_response(resp)
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]:
"""List all running background processes in the capsule.
@ -484,7 +490,11 @@ class AsyncCommands:
f"/v1/capsules/{self._capsule_id}/exec/stream",
self._http,
) as ws: # type: httpx_ws.AsyncWebSocketSession
await ws.send_text(json.dumps(_build_stream_start(cmd, args)))
if 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:
while True:
raw = await ws.receive_json()

View File

@ -164,17 +164,4 @@ def __getattr__(name: str) -> type:
stacklevel=2,
)
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}")

View File

@ -39,46 +39,6 @@ def _find_entry(list_fn, path: str) -> FileEntry | 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:
"""Sync filesystem interface. Accessed via ``capsule.files``."""
@ -223,18 +183,25 @@ class Files:
stream (Iterator[bytes]): Iterable of byte chunks to upload.
"""
boundary = os.urandom(16).hex().encode("utf-8")
preamble, trailer = _multipart_frame(path, boundary)
def _multipart() -> Iterator[bytes]:
yield preamble
yield b"--" + boundary + b"\r\n"
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:
yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8")
yield trailer
yield b"\r\n--" + boundary + b"--\r\n"
resp = self._http.post(
f"/v1/capsules/{self._capsule_id}/files/stream/write",
content=_multipart(),
headers=_multipart_headers(boundary),
headers={
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}",
"Transfer-Encoding": "chunked",
},
)
_raise_for_status(resp)
@ -373,9 +340,11 @@ class AsyncFiles:
json={"path": path},
)
if _is_already_exists(resp):
existing = await _async_find_entry(self.list, path)
if existing is not None:
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
parsed = MakeDirResponse.model_validate(handle_response(resp))
if parsed.entry is None:
raise RuntimeError("mkdir response missing entry")
@ -408,18 +377,25 @@ class AsyncFiles:
upload.
"""
boundary = os.urandom(16).hex().encode("utf-8")
preamble, trailer = _multipart_frame(path, boundary)
async def _multipart() -> AsyncIterator[bytes]:
yield preamble
yield b"--" + boundary + b"\r\n"
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:
yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8")
yield trailer
yield b"\r\n--" + boundary + b"--\r\n"
resp = await self._http.post(
f"/v1/capsules/{self._capsule_id}/files/stream/write",
content=_multipart(),
headers=_multipart_headers(boundary),
headers={
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}",
"Transfer-Encoding": "chunked",
},
)
_raise_for_status(resp)

View File

@ -1,31 +1,65 @@
from wrenn.models._generated import (
Actor,
APIKeyResponse,
Capsule,
CapsuleMetrics,
CapsuleStats,
CreateAPIKeyRequest,
CreateCapsuleRequest,
CreateHostRequest,
CreateHostResponse,
CreateSnapshotRequest,
Encoding,
Error,
Error1,
ExecRequest,
ExecResponse,
FileEntry,
Host,
ListDirRequest,
ListDirResponse,
LoginRequest,
MakeDirRequest,
MakeDirResponse,
MetricPoint,
Resource,
SSEEvent,
ReadFileRequest,
RegisterHostRequest,
RegisterHostResponse,
RemoveRequest,
SignupRequest,
Status,
Status1,
Template,
UsageResponse,
Type,
Type1,
Type2,
)
__all__ = [
"Actor",
"Capsule",
"CapsuleMetrics",
"CapsuleStats",
"APIKeyResponse",
"CreateAPIKeyRequest",
"CreateHostRequest",
"CreateHostResponse",
"CreateCapsuleRequest",
"CreateSnapshotRequest",
"Encoding",
"Error",
"Error1",
"ExecRequest",
"ExecResponse",
"FileEntry",
"Host",
"ListDirRequest",
"ListDirResponse",
"LoginRequest",
"MakeDirRequest",
"MakeDirResponse",
"MetricPoint",
"Resource",
"SSEEvent",
"ReadFileRequest",
"RegisterHostRequest",
"RegisterHostResponse",
"RemoveRequest",
"Capsule",
"SignupRequest",
"Status",
"Status1",
"Template",
"UsageResponse",
"Type",
"Type1",
"Type2",
]

View File

@ -1,20 +1,79 @@
# generated by datamodel-codegen:
# filename: openapi.yaml
# timestamp: 2026-06-22T22:24:45+00:00
# timestamp: 2026-05-19T08:54:50+00:00
from __future__ import annotations
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
from typing import Annotated, Any
from datetime import date as date_aliased
from enum import StrEnum
from typing import Annotated
from pydantic import AwareDatetime, BaseModel, Field
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 SessionResponse(BaseModel):
"""
Returned by login, activate, and switch-team. The actual auth credential
is the wrenn_sid cookie set on the response. The body carries identity
data the SPA needs to bootstrap.
"""
user_id: str | None = None
team_id: str | None = None
email: str | None = None
name: str | None = None
role: str | None = None
is_admin: bool | 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-ubuntu"
template: str | None = "minimal"
vcpus: int | None = 1
memory_mb: int | None = 512
disk_size_mb: Annotated[
int | None,
Field(
description="Maximum size of the per-capsule copy-on-write disk in MB. Capped at 5 GB by default; the actual size is max(disk_size_mb, origin rootfs size).\n"
),
] = 5120
timeout_sec: Annotated[
int | None,
Field(
@ -22,12 +81,6 @@ class CreateCapsuleRequest(BaseModel):
ge=0,
),
] = 0
metadata: Annotated[
dict[str, str] | None,
Field(
description="Optional user-supplied key/value labels attached to the capsule. Max 20 keys; keys match [a-zA-Z0-9][a-zA-Z0-9._-]{0,63}; values are at most 64 characters. Reserved system keys (kernel_version, vmm_version, agent_version, envd_version) are rejected. Metadata is set at create-time only.\n"
),
] = None
class Point(BaseModel):
@ -54,6 +107,7 @@ 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):
@ -94,7 +148,6 @@ class Status(StrEnum):
running = "running"
pausing = "pausing"
paused = "paused"
snapshotting = "snapshotting"
resuming = "resuming"
stopping = "stopping"
hibernated = "hibernated"
@ -110,6 +163,8 @@ class Capsule(BaseModel):
vcpus: int | None = None
memory_mb: int | None = None
timeout_sec: int | None = None
guest_ip: str | None = None
host_ip: str | None = None
created_at: AwareDatetime | None = None
started_at: AwareDatetime | None = None
last_active_at: AwareDatetime | None = None
@ -117,18 +172,10 @@ class Capsule(BaseModel):
metadata: Annotated[
dict[str, str] | None,
Field(
description="User-supplied key/value labels attached at create-time. Once the\ncapsule is running this also carries agent-side system version info\n(kernel_version, vmm_version, agent_version, envd_version); those\nreserved keys are owned by the system and cannot be set by users.\n"
),
] = None
disk_size_mb: Annotated[
int | None, Field(description="Maximum disk capacity in MiB.")
] = None
disk_used_mb: Annotated[
int | None,
Field(
description="Current disk usage in MiB. Only populated on individual capsule GET; omitted in list responses."
description="Free-form key/value labels attached at create-time. Also carries\nagent-side version info (kernel_version, vmm_version,\nagent_version, envd_version) when running.\n"
),
] = None
disk_size_mb: int | None = None
class CreateSnapshotRequest(BaseModel):
@ -156,13 +203,7 @@ class Template(BaseModel):
platform: Annotated[
bool | None,
Field(
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"
),
] = None
protected: Annotated[
bool | None,
Field(
description="True for built-in system base templates (minimal-ubuntu,\nminimal-alpine, minimal-arch, minimal-fedora). Protected templates\ncannot be deleted.\n"
description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal` rootfs). False for team-owned\nsnapshot templates.\n"
),
] = None
metadata: dict[str, str] | None = None
@ -190,14 +231,12 @@ class ExecRequest(BaseModel):
envs: Annotated[
dict[str, str] | None,
Field(
description="Environment variables for the process (applies to both foreground and background exec)"
description="Environment variables for the process (background exec only)"
),
] = None
cwd: Annotated[
str | None,
Field(
description="Working directory for the process (applies to both foreground and background exec)"
),
Field(description="Working directory for the process (background exec only)"),
] = None
@ -280,6 +319,12 @@ class FileEntry(BaseModel):
symlink_target: str | None = None
class MakeDirRequest(BaseModel):
path: Annotated[
str, Field(description="Directory path to create inside the capsule")
]
class MakeDirResponse(BaseModel):
entry: FileEntry | None = None
@ -288,6 +333,158 @@ 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 capsules 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 capsules 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"
@ -317,16 +514,141 @@ class MetricPoint(BaseModel):
] = None
class Error1(BaseModel):
class Provider(StrEnum):
discord = "discord"
slack = "slack"
teams = "teams"
googlechat = "googlechat"
telegram = "telegram"
matrix = "matrix"
webhook = "webhook"
class Event(StrEnum):
capsule_create = "capsule.create"
capsule_pause = "capsule.pause"
capsule_resume = "capsule.resume"
capsule_destroy = "capsule.destroy"
template_snapshot_create = "template.snapshot.create"
template_snapshot_delete = "template.snapshot.delete"
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 Error(BaseModel):
error: Error1 | None = None
class Error1(BaseModel):
error: Error2 | None = None
class Event(StrEnum):
class ActorType(StrEnum):
user = "user"
api_key = "api_key"
host = "host"
system = "system"
class Status2(StrEnum):
success = "success"
failure = "failure"
class AuditLogEntry(BaseModel):
id: str | None = None
actor_type: ActorType | None = None
actor_id: str | None = None
actor_name: str | None = None
resource_type: str | None = None
resource_id: str | None = None
action: str | None = None
scope: str | None = None
status: Status2 | None = None
metadata: dict[str, Any] | None = None
created_at: AwareDatetime | None = None
class Event2(StrEnum):
connected = "connected"
capsule_create = "capsule.create"
capsule_pause = "capsule.pause"
@ -356,14 +678,14 @@ class Resource(BaseModel):
type: str | None = None
class Type2(StrEnum):
class Type4(StrEnum):
user = "user"
api_key = "api_key"
system = "system"
class Actor(BaseModel):
type: Type2 | None = None
type: Type4 | None = None
id: str | None = None
name: str | None = None
@ -375,7 +697,7 @@ class SSEEvent(BaseModel):
"""
event: Event | None = None
event: Event2 | None = None
outcome: Annotated[
Outcome | None,
Field(
@ -404,6 +726,30 @@ class ListDirResponse(BaseModel):
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

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import base64
import json
from collections.abc import AsyncIterator, Iterator
from enum import StrEnum
@ -7,7 +8,6 @@ from typing import Any
import httpx_ws
from pydantic import BaseModel
from wsproto.events import BytesMessage, TextMessage
# A clean (``WebSocketDisconnect``) or abrupt (``WebSocketNetworkError``) close
# both mean the PTY stream has ended; iteration must stop on either.
@ -31,8 +31,7 @@ class PtyEvent(BaseModel):
fatal: bool | None = None
def _parse_control_event(raw: dict[str, Any]) -> PtyEvent:
"""Parse a JSON control frame from the server."""
def _parse_pty_event(raw: dict[str, Any]) -> PtyEvent:
msg_type = raw.get("type", "")
if msg_type == "started":
return PtyEvent(
@ -40,6 +39,10 @@ def _parse_control_event(raw: dict[str, Any]) -> PtyEvent:
pid=raw.get("pid"),
tag=raw.get("tag"),
)
if msg_type == "output":
raw_data = raw.get("data", "")
decoded = base64.b64decode(raw_data) if raw_data else b""
return PtyEvent(type=PtyEventType.output, data=decoded)
if msg_type == "exit":
return PtyEvent(type=PtyEventType.exit, exit_code=raw.get("exit_code", -1))
if msg_type == "error":
@ -130,10 +133,10 @@ class PtySession:
"""Send raw bytes to the PTY stdin.
Args:
data: Raw bytes to send. Delivered as a binary WebSocket frame
verbatim — no JSON wrapping, no base64.
data: Raw bytes to send. Base64-encoded internally.
"""
self._ws.send_bytes(data)
encoded = base64.b64encode(data).decode("ascii")
self._ws.send_text(json.dumps({"type": "input", "data": encoded}))
def resize(self, cols: int, rows: int) -> None:
"""Resize the PTY terminal.
@ -157,34 +160,27 @@ class PtySession:
return self
def __next__(self) -> PtyEvent:
while True:
if self._done:
raise StopIteration
try:
msg = self._ws.receive()
except _WS_CLOSED:
raise StopIteration
if isinstance(msg, BytesMessage):
return PtyEvent(type=PtyEventType.output, data=msg.data)
if isinstance(msg, TextMessage):
event = _parse_control_event(json.loads(msg.data))
else:
# Ignore other wsproto events (Ping/Pong/etc handled by httpx_ws).
continue
if event.type == PtyEventType.started:
if event.tag is not None:
self._tag = event.tag
if event.pid is not None:
self._pid = event.pid
if event.type == PtyEventType.ping:
self._send_pong()
if event.type == PtyEventType.exit:
self._done = True
return event
if event.type == PtyEventType.error and event.fatal:
self._done = True
return event
if self._done:
raise StopIteration
try:
raw = self._ws.receive_text()
except _WS_CLOSED:
raise StopIteration
event = _parse_pty_event(json.loads(raw))
if event.type == PtyEventType.started:
if event.tag is not None:
self._tag = event.tag
if event.pid is not None:
self._pid = event.pid
if event.type == PtyEventType.ping:
self._send_pong()
if event.type == PtyEventType.exit:
self._done = True
return event
if event.type == PtyEventType.error and event.fatal:
self._done = True
return event
return event
def __enter__(self) -> PtySession:
return self
@ -273,10 +269,10 @@ class AsyncPtySession:
"""Send raw bytes to the PTY stdin.
Args:
data: Raw bytes to send. Delivered as a binary WebSocket frame
verbatim — no JSON wrapping, no base64.
data: Raw bytes to send. Base64-encoded internally.
"""
await self._ws.send_bytes(data)
encoded = base64.b64encode(data).decode("ascii")
await self._ws.send_text(json.dumps({"type": "input", "data": encoded}))
async def resize(self, cols: int, rows: int) -> None:
"""Resize the PTY terminal.
@ -302,34 +298,27 @@ class AsyncPtySession:
return self
async def __anext__(self) -> PtyEvent:
while True:
if self._done:
raise StopAsyncIteration
try:
msg = await self._ws.receive()
except _WS_CLOSED:
raise StopAsyncIteration
if isinstance(msg, BytesMessage):
return PtyEvent(type=PtyEventType.output, data=msg.data)
if isinstance(msg, TextMessage):
event = _parse_control_event(json.loads(msg.data))
else:
# Ignore other wsproto events (Ping/Pong/etc handled by httpx_ws).
continue
if event.type == PtyEventType.started:
if event.tag is not None:
self._tag = event.tag
if event.pid is not None:
self._pid = event.pid
if event.type == PtyEventType.ping:
await self._send_pong()
if event.type == PtyEventType.exit:
self._done = True
return event
if event.type == PtyEventType.error and event.fatal:
self._done = True
return event
if self._done:
raise StopAsyncIteration
try:
raw = await self._ws.receive_text()
except _WS_CLOSED:
raise StopAsyncIteration
event = _parse_pty_event(json.loads(raw))
if event.type == PtyEventType.started:
if event.tag is not None:
self._tag = event.tag
if event.pid is not None:
self._pid = event.pid
if event.type == PtyEventType.ping:
await self._send_pong()
if event.type == PtyEventType.exit:
self._done = True
return event
if event.type == PtyEventType.error and event.fatal:
self._done = True
return event
return event
async def __aenter__(self) -> AsyncPtySession:
return self

View File

@ -45,16 +45,6 @@ class TestBuildHttpProxyUrl:
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:
@respx.mock
@ -232,7 +222,7 @@ class TestGetUrlPublic:
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"
assert cap.get_url(8080) == "https://8080-cl-99.app.wrenn.dev"
@respx.mock
def test_sync_get_url_localhost(self):
@ -252,7 +242,7 @@ class TestGetUrlPublic:
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"
assert cap.get_url(5000) == "https://5000-cl-async.app.wrenn.dev"
await cap._client.aclose()

View File

@ -13,16 +13,9 @@ from wrenn.exceptions import (
WrennValidationError,
)
from wrenn.models import (
Actor,
Capsule,
CapsuleMetrics,
CapsuleStats,
MetricPoint,
Resource,
SSEEvent,
Status,
Template,
UsageResponse,
)
BASE = "https://app.wrenn.dev/api"
@ -110,212 +103,6 @@ class TestCapsules:
client.capsules.ping("sb-1")
assert route.called
@respx.mock
def test_stats(self, client):
route = respx.get(f"{BASE}/v1/capsules/stats").respond(
200,
json={
"range": "6h",
"current": {"running_count": 2, "vcpus_reserved": 4},
"peaks": {"running_count": 9},
"series": {"running": [1, 2, 2]},
},
)
stats = client.capsules.stats(range="6h")
assert "range=6h" in str(route.calls[0].request.url)
assert stats.current and stats.current.running_count == 2
assert stats.peaks and stats.peaks.running_count == 9
@respx.mock
def test_usage_passes_date_params(self, client):
import datetime as _dt
route = respx.get(f"{BASE}/v1/capsules/usage").respond(
200, json={"from": "2026-06-01", "to": "2026-06-22", "points": []}
)
client.capsules.usage(from_=_dt.date(2026, 6, 1), to="2026-06-22")
url = str(route.calls[0].request.url)
assert "from=2026-06-01" in url
assert "to=2026-06-22" in url
@respx.mock
def test_metrics(self, client):
respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond(
200,
json={
"sandbox_id": "sb-1",
"range": "10m",
"points": [{"timestamp_unix": 1, "cpu_pct": 12.5, "mem_bytes": 4096}],
},
)
m = client.capsules.metrics("sb-1")
assert m.sandbox_id == "sb-1"
assert m.points and m.points[0].cpu_pct == 12.5
assert isinstance(m, CapsuleMetrics)
assert isinstance(m.points[0], MetricPoint)
@respx.mock
def test_stats_default_omits_range(self, client):
route = respx.get(f"{BASE}/v1/capsules/stats").respond(
200,
json={"range": "1h", "current": {}, "peaks": {}, "series": {}},
)
stats = client.capsules.stats()
assert "range=" not in str(route.calls[0].request.url)
assert isinstance(stats, CapsuleStats)
@respx.mock
def test_usage_default_omits_params(self, client):
route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []})
usage = client.capsules.usage()
url = str(route.calls[0].request.url)
assert "from=" not in url
assert "to=" not in url
assert isinstance(usage, UsageResponse)
@respx.mock
def test_metrics_default_omits_range(self, client):
route = respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond(
200, json={"sandbox_id": "sb-1", "points": []}
)
client.capsules.metrics("sb-1")
assert "range=" not in str(route.calls[0].request.url)
@respx.mock
def test_metrics_not_found(self, client):
respx.get(f"{BASE}/v1/capsules/nope/metrics").respond(
404,
json={"error": {"code": "not_found", "message": "capsule not found"}},
)
with pytest.raises(WrennNotFoundError):
client.capsules.metrics("nope")
@respx.mock
def test_stats_auth_error(self, client):
respx.get(f"{BASE}/v1/capsules/stats").respond(
401,
json={"error": {"code": "unauthorized", "message": "bad key"}},
)
with pytest.raises(WrennAuthenticationError):
client.capsules.stats()
@respx.mock
def test_usage_string_dates(self, client):
route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []})
client.capsules.usage(from_="2026-01-01", to="2026-01-31")
url = str(route.calls[0].request.url)
assert "from=2026-01-01" in url
assert "to=2026-01-31" in url
@respx.mock
def test_usage_partial_dates(self, client):
route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []})
client.capsules.usage(from_="2026-01-01")
url = str(route.calls[0].request.url)
assert "from=2026-01-01" in url
assert "to=" not in url
class TestEvents:
@respx.mock
def test_stream_parses_sse_frames(self, client):
body = (
": keepalive\n"
"event: capsule.create\n"
'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n'
"\n"
"event: capsule.destroy\n"
'data: {"event":"capsule.destroy","resource":{"id":"sb-1","type":"capsule"}}\n'
"\n"
)
respx.get(f"{BASE}/v1/events/stream").respond(
200,
headers={"content-type": "text/event-stream"},
content=body,
)
events = list(client.events.stream())
assert [e.event.value for e in events] == [
"capsule.create",
"capsule.destroy",
]
assert events[0].resource and events[0].resource.id == "sb-1"
@respx.mock
def test_stream_multiline_data(self, client):
body = (
"event: capsule.create\n"
'data: {"event":"capsule.create",\n'
'data: "resource":{"id":"sb-1","type":"capsule"}}\n'
"\n"
)
respx.get(f"{BASE}/v1/events/stream").respond(
200,
headers={"content-type": "text/event-stream"},
content=body,
)
events = list(client.events.stream())
assert len(events) == 1
assert events[0].resource.id == "sb-1"
@respx.mock
def test_stream_skips_keepalive_only(self, client):
body = ": keepalive\n\n: keepalive\n\n"
respx.get(f"{BASE}/v1/events/stream").respond(
200,
headers={"content-type": "text/event-stream"},
content=body,
)
assert list(client.events.stream()) == []
@respx.mock
def test_stream_ignores_incomplete_trailing_frame(self, client):
body = (
'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n'
"\n"
'data: {"event":"capsule.destroy"' # no closing brace, no blank line
)
respx.get(f"{BASE}/v1/events/stream").respond(
200,
headers={"content-type": "text/event-stream"},
content=body,
)
events = list(client.events.stream())
assert len(events) == 1
assert events[0].event.value == "capsule.create"
@respx.mock
def test_stream_full_payload_round_trip(self, client):
body = (
'data: {"event":"capsule.create",'
'"outcome":"success",'
'"resource":{"id":"sb-1","type":"capsule"},'
'"actor":{"type":"api_key","id":"key-1","name":"ci"},'
'"metadata":{"reason":"manual"}}\n'
"\n"
)
respx.get(f"{BASE}/v1/events/stream").respond(
200,
headers={"content-type": "text/event-stream"},
content=body,
)
events = list(client.events.stream())
assert len(events) == 1
ev = events[0]
assert isinstance(ev, SSEEvent)
assert isinstance(ev.resource, Resource)
assert isinstance(ev.actor, Actor)
assert ev.actor.name == "ci"
assert ev.metadata == {"reason": "manual"}
@respx.mock
def test_stream_raises_on_4xx(self, client):
respx.get(f"{BASE}/v1/events/stream").respond(
401,
json={"error": {"code": "unauthorized", "message": "bad key"}},
)
with pytest.raises(WrennAuthenticationError):
list(client.events.stream())
class TestSnapshots:
@respx.mock
@ -474,189 +261,3 @@ class TestAsyncClient:
)
with pytest.raises(WrennNotFoundError):
await async_client.capsules.get("nope")
@pytest.mark.asyncio
@respx.mock
async def test_async_stats(self, async_client):
async with async_client:
route = respx.get(f"{BASE}/v1/capsules/stats").respond(
200,
json={
"range": "24h",
"current": {"running_count": 1},
"peaks": {"running_count": 5},
"series": {"running": [1]},
},
)
stats = await async_client.capsules.stats(range="24h")
assert "range=24h" in str(route.calls[0].request.url)
assert isinstance(stats, CapsuleStats)
assert stats.current.running_count == 1
@pytest.mark.asyncio
@respx.mock
async def test_async_usage(self, async_client):
import datetime as _dt
async with async_client:
route = respx.get(f"{BASE}/v1/capsules/usage").respond(
200,
json={
"from": "2026-06-01",
"to": "2026-06-22",
"points": [
{
"date": "2026-06-01",
"cpu_minutes": 1.5,
"ram_mb_minutes": 200.0,
}
],
},
)
usage = await async_client.capsules.usage(
from_=_dt.date(2026, 6, 1), to="2026-06-22"
)
url = str(route.calls[0].request.url)
assert "from=2026-06-01" in url
assert "to=2026-06-22" in url
assert isinstance(usage, UsageResponse)
assert usage.points and usage.points[0].cpu_minutes == 1.5
@pytest.mark.asyncio
@respx.mock
async def test_async_metrics(self, async_client):
async with async_client:
respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond(
200,
json={
"sandbox_id": "sb-1",
"range": "2h",
"points": [
{"timestamp_unix": 1, "cpu_pct": 33.0, "mem_bytes": 1024}
],
},
)
m = await async_client.capsules.metrics("sb-1", range="2h")
assert isinstance(m, CapsuleMetrics)
assert m.points[0].cpu_pct == 33.0
@pytest.mark.asyncio
@respx.mock
async def test_async_metrics_not_found(self, async_client):
async with async_client:
respx.get(f"{BASE}/v1/capsules/nope/metrics").respond(
404,
json={"error": {"code": "not_found", "message": "not found"}},
)
with pytest.raises(WrennNotFoundError):
await async_client.capsules.metrics("nope")
@pytest.mark.asyncio
@respx.mock
async def test_async_events_stream(self, async_client):
async with async_client:
body = (
": keepalive\n"
'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n'
"\n"
'data: {"event":"capsule.destroy","resource":{"id":"sb-1","type":"capsule"}}\n'
"\n"
)
respx.get(f"{BASE}/v1/events/stream").respond(
200,
headers={"content-type": "text/event-stream"},
content=body,
)
events = [ev async for ev in async_client.events.stream()]
assert [e.event.value for e in events] == [
"capsule.create",
"capsule.destroy",
]
@pytest.mark.asyncio
@respx.mock
async def test_async_events_raises_on_4xx(self, async_client):
async with async_client:
respx.get(f"{BASE}/v1/events/stream").respond(
401,
json={"error": {"code": "unauthorized", "message": "bad key"}},
)
with pytest.raises(WrennAuthenticationError):
async for _ in async_client.events.stream():
pass
@pytest.mark.asyncio
@respx.mock
async def test_async_events_multiline_data(self, async_client):
async with async_client:
body = (
'data: {"event":"capsule.create",\n'
'data: "resource":{"id":"sb-1","type":"capsule"}}\n'
"\n"
)
respx.get(f"{BASE}/v1/events/stream").respond(
200,
headers={"content-type": "text/event-stream"},
content=body,
)
events = [ev async for ev in async_client.events.stream()]
assert len(events) == 1
assert events[0].resource.id == "sb-1"
class TestPackageSurface:
def test_version(self):
import wrenn
assert wrenn.__version__ == "0.3.0"
def test_new_models_exported(self):
import wrenn.models as m
for name in (
"Actor",
"CapsuleMetrics",
"CapsuleStats",
"MetricPoint",
"Resource",
"SSEEvent",
"UsageResponse",
):
assert hasattr(m, name), name
assert name in m.__all__
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

View File

@ -430,10 +430,12 @@ class TestCodeRunnerMimeTypes:
def test_requests_status_code(self):
ex = self._run(
"import requests\n"
"r = requests.get('http://httpbingo.org/status/418', timeout=10)\n"
"r = requests.get('https://httpbin.org/status/204', timeout=10)\n"
"r.status_code\n"
)
assert ex.text == "418"
if ex.error is not None:
pytest.skip(f"network unavailable: {ex.error.name}")
assert ex.text == "204"
class TestCodeRunnerIsolation:
@ -479,41 +481,58 @@ class TestCodeRunnerAsync:
@pytest.mark.asyncio
async def test_async_simple(self):
async with await AsyncCapsule.create(wait=True) as c:
c = await AsyncCapsule.create(wait=True)
try:
ex = await c.run_code("21 * 2")
assert ex.error is None
assert ex.text == "42"
finally:
await c.close()
await c.destroy()
@pytest.mark.asyncio
async def test_async_persistence(self):
async with await AsyncCapsule.create(wait=True) as c:
c = await AsyncCapsule.create(wait=True)
try:
await c.run_code("v = 'persisted'")
ex = await c.run_code("v")
assert ex.text == "'persisted'"
finally:
await c.close()
await c.destroy()
@pytest.mark.asyncio
async def test_async_callbacks(self):
async with await AsyncCapsule.create(wait=True) as c:
c = await AsyncCapsule.create(wait=True)
try:
chunks: list[str] = []
await c.run_code(
"print('async out')",
on_stdout=chunks.append,
)
assert any("async out" in s for s in chunks)
finally:
await c.close()
await c.destroy()
@pytest.mark.asyncio
async def test_async_context_manager(self):
async with await AsyncCapsule.create(wait=True) as c:
c = await AsyncCapsule.create(wait=True)
async with 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"
c1 = await AsyncCapsule.create(wait=True)
c2 = await AsyncCapsule.create(wait=True)
try:
r1, r2 = await asyncio.gather(
c1.run_code("1 + 1"),
c2.run_code("10 * 10"),
)
assert r1.text == "2"
assert r2.text == "100"
finally:
await asyncio.gather(c1.close(), c2.close(), return_exceptions=True)
await asyncio.gather(c1.destroy(), c2.destroy(), return_exceptions=True)

View File

@ -263,7 +263,7 @@ 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"
proxy_base = "https://8888-sb-1.app.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"}
@ -279,7 +279,7 @@ class TestEnsureKernel:
@respx.mock
def test_reuses_existing_wrenn_kernel(self):
c = _make_capsule()
proxy_base = "https://8888-sb-1.wrenn.dev"
proxy_base = "https://8888-sb-1.app.wrenn.dev"
respx.get(f"{proxy_base}/api/kernels").respond(
200,
json=[
@ -295,7 +295,7 @@ class TestEnsureKernel:
@respx.mock
def test_creates_when_only_other_kernels_exist(self):
c = _make_capsule()
proxy_base = "https://8888-sb-1.wrenn.dev"
proxy_base = "https://8888-sb-1.app.wrenn.dev"
respx.get(f"{proxy_base}/api/kernels").respond(
200, json=[{"id": "k-other", "name": "python3"}]
)
@ -308,7 +308,7 @@ class TestEnsureKernel:
@respx.mock
def test_caches_kernel_id(self):
c = _make_capsule()
proxy_base = "https://8888-sb-1.wrenn.dev"
proxy_base = "https://8888-sb-1.app.wrenn.dev"
route = respx.get(f"{proxy_base}/api/kernels").respond(
200, json=[{"id": "k-1", "name": "wrenn"}]
)
@ -322,7 +322,7 @@ class TestEnsureKernel:
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"
proxy_base = "https://8888-sb-1.app.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"}
@ -334,7 +334,7 @@ class TestEnsureKernel:
@respx.mock
def test_retries_on_5xx_then_succeeds(self):
c = _make_capsule()
proxy_base = "https://8888-sb-1.wrenn.dev"
proxy_base = "https://8888-sb-1.app.wrenn.dev"
responses = [
httpx.Response(503),
httpx.Response(200, json=[{"id": "k-1", "name": "wrenn"}]),
@ -347,7 +347,7 @@ class TestEnsureKernel:
@respx.mock
def test_raises_on_4xx(self):
c = _make_capsule()
proxy_base = "https://8888-sb-1.wrenn.dev"
proxy_base = "https://8888-sb-1.app.wrenn.dev"
respx.get(f"{proxy_base}/api/kernels").respond(401)
with pytest.raises(httpx.HTTPStatusError):
c._ensure_kernel(jupyter_timeout=2)
@ -355,7 +355,7 @@ class TestEnsureKernel:
@respx.mock
def test_timeout_raises(self):
c = _make_capsule()
proxy_base = "https://8888-sb-1.wrenn.dev"
proxy_base = "https://8888-sb-1.app.wrenn.dev"
respx.get(f"{proxy_base}/api/kernels").respond(503)
with patch("time.sleep"):
with pytest.raises(TimeoutError):
@ -388,214 +388,6 @@ class TestJupyterRequest:
assert a["header"]["msg_id"] != b["header"]["msg_id"]
# ───────────────────────── _protocol direct helpers ─────────────────────────
class TestPickKernelId:
def test_returns_first_matching_kernel(self):
from wrenn.code_runner._protocol import pick_kernel_id
kernels = [
{"id": "k-1", "name": "python3"},
{"id": "k-2", "name": "wrenn"},
{"id": "k-3", "name": "wrenn"},
]
assert pick_kernel_id(kernels, "wrenn") == "k-2"
def test_returns_none_when_no_match(self):
from wrenn.code_runner._protocol import pick_kernel_id
kernels = [{"id": "k-1", "name": "python3"}]
assert pick_kernel_id(kernels, "wrenn") is None
def test_returns_none_on_empty_list(self):
from wrenn.code_runner._protocol import pick_kernel_id
assert pick_kernel_id([], "wrenn") is None
def test_ignores_entries_missing_name(self):
from wrenn.code_runner._protocol import pick_kernel_id
kernels = [{"id": "k-1"}, {"id": "k-2", "name": "wrenn"}]
assert pick_kernel_id(kernels, "wrenn") == "k-2"
class TestValidateLanguage:
def test_python_accepted(self):
from wrenn.code_runner._protocol import validate_language
validate_language("python") # no raise
def test_other_language_raises(self):
from wrenn.code_runner._protocol import validate_language
with pytest.raises(ValueError, match="not supported"):
validate_language("r")
class TestBuildWsUrl:
def test_uses_proxy_domain_when_given(self):
from wrenn.code_runner._protocol import build_ws_url
url = build_ws_url(
base_url="https://app.wrenn.dev/api",
capsule_id="sb-1",
kernel_id="k-1",
proxy_domain="wrenn.dev",
)
assert url.startswith("wss://")
assert "8888-sb-1.wrenn.dev" in url
assert url.endswith("/api/kernels/k-1/channels")
def test_falls_back_to_base_host(self):
from wrenn.code_runner._protocol import build_ws_url
url = build_ws_url(
base_url="http://localhost:8080/api",
capsule_id="sb-1",
kernel_id="k-1",
)
# localhost stays http→ws (not wss) only if helper preserves scheme
assert "8888-sb-1.localhost:8080" in url
assert url.endswith("/api/kernels/k-1/channels")
class TestApplyKernelMessage:
def _make_execution(self):
from wrenn.code_runner.models import Execution, Logs
return Execution(results=[], logs=Logs(stdout=[], stderr=[]), error=None)
def test_ignores_other_parent(self):
from wrenn.code_runner._protocol import apply_kernel_message
execution = self._make_execution()
msg = {
"msg_type": "stream",
"header": {"msg_type": "stream"},
"parent_header": {"msg_id": "other"},
"content": {"name": "stdout", "text": "hi"},
}
emitted: list = []
done = apply_kernel_message(
msg, "mine", execution, emitted.append, None, None, None
)
assert done is False
assert execution.logs.stdout == []
def test_stream_stdout_routed(self):
from wrenn.code_runner._protocol import apply_kernel_message
execution = self._make_execution()
msg = {
"msg_type": "stream",
"parent_header": {"msg_id": "mine"},
"content": {"name": "stdout", "text": "hi"},
}
outs: list[str] = []
apply_kernel_message(
msg, "mine", execution, lambda e: None, None, outs.append, None
)
assert execution.logs.stdout == ["hi"]
assert outs == ["hi"]
def test_stream_stderr_routed(self):
from wrenn.code_runner._protocol import apply_kernel_message
execution = self._make_execution()
msg = {
"msg_type": "stream",
"parent_header": {"msg_id": "mine"},
"content": {"name": "stderr", "text": "boom"},
}
errs: list[str] = []
apply_kernel_message(
msg, "mine", execution, lambda e: None, None, None, errs.append
)
assert execution.logs.stderr == ["boom"]
assert errs == ["boom"]
def test_execute_result_marks_main(self):
from wrenn.code_runner._protocol import apply_kernel_message
execution = self._make_execution()
msg = {
"msg_type": "execute_result",
"parent_header": {"msg_id": "mine"},
"content": {
"data": {"text/plain": "42"},
"execution_count": 7,
},
}
results: list = []
apply_kernel_message(
msg, "mine", execution, lambda e: None, results.append, None, None
)
assert execution.execution_count == 7
assert execution.results[0].is_main_result is True
assert results and results[0].is_main_result is True
def test_display_data_is_not_main(self):
from wrenn.code_runner._protocol import apply_kernel_message
execution = self._make_execution()
msg = {
"msg_type": "display_data",
"parent_header": {"msg_id": "mine"},
"content": {"data": {"text/plain": "x"}},
}
apply_kernel_message(msg, "mine", execution, lambda e: None, None, None, None)
assert execution.results[0].is_main_result is False
assert execution.execution_count is None
def test_error_message_emits_error(self):
from wrenn.code_runner._protocol import apply_kernel_message
execution = self._make_execution()
msg = {
"msg_type": "error",
"parent_header": {"msg_id": "mine"},
"content": {
"ename": "ValueError",
"evalue": "bad",
"traceback": ["line1", "line2"],
},
}
emitted: list = []
apply_kernel_message(msg, "mine", execution, emitted.append, None, None, None)
assert len(emitted) == 1
assert emitted[0].name == "ValueError"
assert emitted[0].traceback == "line1\nline2"
def test_idle_status_returns_true(self):
from wrenn.code_runner._protocol import apply_kernel_message
execution = self._make_execution()
msg = {
"msg_type": "status",
"parent_header": {"msg_id": "mine"},
"content": {"execution_state": "idle"},
}
done = apply_kernel_message(
msg, "mine", execution, lambda e: None, None, None, None
)
assert done is True
def test_busy_status_returns_false(self):
from wrenn.code_runner._protocol import apply_kernel_message
execution = self._make_execution()
msg = {
"msg_type": "status",
"parent_header": {"msg_id": "mine"},
"content": {"execution_state": "busy"},
}
done = apply_kernel_message(
msg, "mine", execution, lambda e: None, None, None, None
)
assert done is False
# ───────────────────────── run_code (WS-mocked) ─────────────────────────
@ -1021,7 +813,7 @@ class TestAsyncEnsureKernel:
@respx.mock
async def test_async_creates_kernel_when_none_exist(self):
c = _make_async_capsule()
proxy_base = "https://8888-sb-1.wrenn.dev"
proxy_base = "https://8888-sb-1.app.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"}
@ -1037,7 +829,7 @@ class TestAsyncEnsureKernel:
@respx.mock
async def test_async_reuses_existing_wrenn_kernel(self):
c = _make_async_capsule()
proxy_base = "https://8888-sb-1.wrenn.dev"
proxy_base = "https://8888-sb-1.app.wrenn.dev"
respx.get(f"{proxy_base}/api/kernels").respond(
200,
json=[
@ -1055,7 +847,7 @@ class TestAsyncEnsureKernel:
@respx.mock
async def test_async_retries_on_5xx_then_succeeds(self):
c = _make_async_capsule()
proxy_base = "https://8888-sb-1.wrenn.dev"
proxy_base = "https://8888-sb-1.app.wrenn.dev"
responses = [
httpx.Response(503),
httpx.Response(200, json=[{"id": "k-1", "name": "wrenn"}]),
@ -1075,7 +867,7 @@ class TestAsyncEnsureKernel:
@respx.mock
async def test_async_raises_on_4xx(self):
c = _make_async_capsule()
proxy_base = "https://8888-sb-1.wrenn.dev"
proxy_base = "https://8888-sb-1.app.wrenn.dev"
respx.get(f"{proxy_base}/api/kernels").respond(401)
with pytest.raises(httpx.HTTPStatusError):
await c._ensure_kernel(jupyter_timeout=2)
@ -1085,7 +877,7 @@ class TestAsyncEnsureKernel:
@respx.mock
async def test_async_caches_kernel_id(self):
c = _make_async_capsule()
proxy_base = "https://8888-sb-1.wrenn.dev"
proxy_base = "https://8888-sb-1.app.wrenn.dev"
route = respx.get(f"{proxy_base}/api/kernels").respond(
200, json=[{"id": "k-1", "name": "wrenn"}]
)

View File

@ -1,11 +1,11 @@
from __future__ import annotations
import base64
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
import respx
from wsproto.events import BytesMessage, TextMessage
from wrenn.capsule import Capsule
from wrenn.models import FileEntry
@ -13,18 +13,9 @@ from wrenn.pty import (
AsyncPtySession,
PtyEventType,
PtySession,
_parse_control_event,
_parse_pty_event,
)
def _text(payload: dict) -> TextMessage:
return TextMessage(data=json.dumps(payload))
def _bytes(data: bytes) -> BytesMessage:
return BytesMessage(data=data)
BASE = "https://app.wrenn.dev/api"
@ -83,32 +74,32 @@ class TestFilesList:
"entries": [
{
"name": "main.py",
"path": "/home/wrenn-user/main.py",
"path": "/home/user/main.py",
"type": "file",
"size": 1024,
"mode": 33188,
"permissions": "-rw-r--r--",
"owner": "wrenn-user",
"group": "wrenn-user",
"owner": "root",
"group": "root",
"modified_at": 1712899200,
"symlink_target": None,
},
{
"name": "config",
"path": "/home/wrenn-user/config",
"path": "/home/user/config",
"type": "directory",
"size": 4096,
"mode": 16877,
"permissions": "drwxr-xr-x",
"owner": "wrenn-user",
"group": "wrenn-user",
"owner": "root",
"group": "root",
"modified_at": 1712899100,
"symlink_target": None,
},
]
},
)
entries = cap.files.list("/home/wrenn-user")
entries = cap.files.list("/home/user")
assert len(entries) == 2
assert isinstance(entries[0], FileEntry)
assert entries[0].name == "main.py"
@ -122,7 +113,7 @@ class TestFilesList:
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
200, json={"entries": []}
)
cap.files.list("/home/wrenn-user", depth=3)
cap.files.list("/home/user", depth=3)
body = json.loads(route.calls[0].request.content)
assert body["depth"] == 3
@ -145,19 +136,19 @@ class TestFilesMakeDir:
json={
"entry": {
"name": "data",
"path": "/home/wrenn-user/data",
"path": "/home/user/data",
"type": "directory",
"size": 4096,
"mode": 16877,
"permissions": "drwxr-xr-x",
"owner": "wrenn-user",
"group": "wrenn-user",
"owner": "root",
"group": "root",
"modified_at": 1712899200,
"symlink_target": None,
}
},
)
entry = cap.files.make_dir("/home/wrenn-user/data")
entry = cap.files.make_dir("/home/user/data")
assert isinstance(entry, FileEntry)
assert entry.name == "data"
assert entry.type == "directory"
@ -175,20 +166,20 @@ class TestFilesMakeDir:
"entries": [
{
"name": "data",
"path": "/home/wrenn-user/data",
"path": "/home/user/data",
"type": "directory",
"size": 4096,
"mode": 16877,
"permissions": "drwxr-xr-x",
"owner": "wrenn-user",
"group": "wrenn-user",
"owner": "root",
"group": "root",
"modified_at": 1712899200,
"symlink_target": None,
}
]
},
)
entry = cap.files.make_dir("/home/wrenn-user/data")
entry = cap.files.make_dir("/home/user/data")
assert entry.name == "data"
@ -197,7 +188,7 @@ class TestFilesRemove:
def test_remove_succeeds(self):
cap = _make_capsule()
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204)
cap.files.remove("/home/wrenn-user/old_data")
cap.files.remove("/home/user/old_data")
assert route.called
@respx.mock
@ -235,37 +226,50 @@ class TestFilesExists:
class TestPtyEventParsing:
def test_started_event(self):
raw = {"type": "started", "tag": "pty-a1b2c3d4", "pid": 42}
event = _parse_control_event(raw)
event = _parse_pty_event(raw)
assert event.type == PtyEventType.started
assert event.pid == 42
assert event.tag == "pty-a1b2c3d4"
def test_output_event_base64(self):
encoded = base64.b64encode(b"ls -la\n").decode()
raw = {"type": "output", "data": encoded}
event = _parse_pty_event(raw)
assert event.type == PtyEventType.output
assert event.data == b"ls -la\n"
def test_output_event_empty(self):
raw = {"type": "output", "data": ""}
event = _parse_pty_event(raw)
assert event.data == b""
def test_exit_event(self):
raw = {"type": "exit", "exit_code": 0}
event = _parse_control_event(raw)
event = _parse_pty_event(raw)
assert event.type == PtyEventType.exit
assert event.exit_code == 0
def test_error_event(self):
raw = {"type": "error", "data": "process not found", "fatal": True}
event = _parse_control_event(raw)
event = _parse_pty_event(raw)
assert event.type == PtyEventType.error
assert event.data == "process not found"
assert event.fatal is True
def test_ping_event(self):
raw = {"type": "ping"}
event = _parse_control_event(raw)
event = _parse_pty_event(raw)
assert event.type == PtyEventType.ping
class TestPtySessionWrite:
def test_write_sends_binary_frame(self):
def test_write_sends_base64_input(self):
ws = MagicMock()
session = PtySession(ws, "cl-abc")
session.write(b"ls -la\n")
ws.send_bytes.assert_called_once_with(b"ls -la\n")
ws.send_text.assert_not_called()
sent = json.loads(ws.send_text.call_args[0][0])
assert sent["type"] == "input"
assert base64.b64decode(sent["data"]) == b"ls -la\n"
class TestPtySessionResize:
@ -299,11 +303,12 @@ class TestPtySessionKill:
class TestPtySessionIteration:
def test_iter_yields_events_until_exit(self):
ws = MagicMock()
ws.receive.side_effect = [
_text({"type": "started", "tag": "pty-abc12345", "pid": 1}),
_bytes(b"hello"),
_text({"type": "exit", "exit_code": 0}),
messages = [
json.dumps({"type": "started", "tag": "pty-abc12345", "pid": 1}),
json.dumps({"type": "output", "data": base64.b64encode(b"hello").decode()}),
json.dumps({"type": "exit", "exit_code": 0}),
]
ws.receive_text.side_effect = messages
session = PtySession(ws, "cl-abc")
events = list(session)
assert len(events) == 3
@ -315,22 +320,12 @@ class TestPtySessionIteration:
assert events[2].type == PtyEventType.exit
assert events[2].exit_code == 0
def test_iter_yields_empty_binary_frame(self):
ws = MagicMock()
ws.receive.side_effect = [
_bytes(b""),
_text({"type": "exit", "exit_code": 0}),
]
session = PtySession(ws, "cl-abc")
events = list(session)
assert events[0].type == PtyEventType.output
assert events[0].data == b""
def test_iter_stops_on_fatal_error(self):
ws = MagicMock()
ws.receive.side_effect = [
_text({"type": "error", "data": "fatal", "fatal": True}),
messages = [
json.dumps({"type": "error", "data": "fatal", "fatal": True}),
]
ws.receive_text.side_effect = messages
session = PtySession(ws, "cl-abc")
events = list(session)
assert len(events) == 1
@ -340,7 +335,7 @@ class TestPtySessionIteration:
import httpx_ws
ws = MagicMock()
ws.receive.side_effect = httpx_ws.WebSocketDisconnect()
ws.receive_text.side_effect = httpx_ws.WebSocketDisconnect()
session = PtySession(ws, "cl-abc")
events = list(session)
assert events == []
@ -349,9 +344,9 @@ class TestPtySessionIteration:
class TestPtySessionPong:
def test_ping_triggers_pong(self):
ws = MagicMock()
ws.receive.side_effect = [
_text({"type": "ping"}),
_text({"type": "exit", "exit_code": 0}),
ws.receive_text.side_effect = [
json.dumps({"type": "ping"}),
json.dumps({"type": "exit", "exit_code": 0}),
]
session = PtySession(ws, "cl-abc")
events = list(session)
@ -361,9 +356,9 @@ class TestPtySessionPong:
def test_no_pong_without_ping(self):
ws = MagicMock()
ws.receive.side_effect = [
_bytes(b""),
_text({"type": "exit", "exit_code": 0}),
ws.receive_text.side_effect = [
json.dumps({"type": "output", "data": ""}),
json.dumps({"type": "exit", "exit_code": 0}),
]
session = PtySession(ws, "cl-abc")
list(session)
@ -416,7 +411,7 @@ class TestPtySessionSendStart:
cols=120,
rows=40,
envs={"TERM": "xterm-256color"},
cwd="/home/wrenn-user",
cwd="/home/user",
)
sent = json.loads(ws.send_text.call_args[0][0])
assert sent["cmd"] == "/bin/zsh"
@ -436,12 +431,13 @@ class TestPtySessionSendConnect:
class TestAsyncPtySession:
@pytest.mark.asyncio
async def test_async_write_sends_binary_frame(self):
async def test_async_write_sends_base64(self):
ws = AsyncMock()
session = AsyncPtySession(ws, "cl-abc")
await session.write(b"hello")
ws.send_bytes.assert_awaited_once_with(b"hello")
ws.send_text.assert_not_called()
sent = json.loads(ws.send_text.call_args[0][0])
assert sent["type"] == "input"
assert base64.b64decode(sent["data"]) == b"hello"
@pytest.mark.asyncio
async def test_async_resize(self):
@ -490,9 +486,9 @@ class TestAsyncPtySession:
@pytest.mark.asyncio
async def test_async_ping_triggers_pong(self):
ws = AsyncMock()
ws.receive.side_effect = [
_text({"type": "ping"}),
_text({"type": "exit", "exit_code": 0}),
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]
@ -512,11 +508,12 @@ class TestAsyncPtySession:
@pytest.mark.asyncio
async def test_async_iteration(self):
ws = AsyncMock()
ws.receive.side_effect = [
_text({"type": "started", "tag": "pty-xyz", "pid": 5}),
_bytes(b"hi"),
_text({"type": "exit", "exit_code": 0}),
messages = [
json.dumps({"type": "started", "tag": "pty-xyz", "pid": 5}),
json.dumps({"type": "output", "data": base64.b64encode(b"hi").decode()}),
json.dumps({"type": "exit", "exit_code": 0}),
]
ws.receive_text.side_effect = messages
session = AsyncPtySession(ws, "cl-abc")
events = []
async for event in session:
@ -525,8 +522,6 @@ class TestAsyncPtySession:
assert events[0].type == PtyEventType.started
assert session.tag == "pty-xyz"
assert session.pid == 5
assert events[1].type == PtyEventType.output
assert events[1].data == b"hi"
assert events[2].type == PtyEventType.exit

View File

@ -7,18 +7,8 @@ from pathlib import Path
import pytest
from wrenn import Capsule, CommandResult
from wrenn.client import WrennClient
from wrenn.commands import CommandHandle, ProcessInfo
from wrenn.exceptions import WrennNotFoundError
from wrenn.models import (
Capsule as CapsuleModel,
CapsuleMetrics,
CapsuleStats,
FileEntry,
SSEEvent,
Status,
UsageResponse,
)
from wrenn.models import Capsule as CapsuleModel, FileEntry, Status
pytestmark = pytest.mark.integration
@ -333,7 +323,7 @@ class TestFiles:
class TestGit:
"""Shared capsule for git operation tests.
Initializes a repo at /home/wrenn-user (default cwd) since the exec API
Initializes a repo at /root (default cwd) since the exec API
does not support the cwd parameter.
"""
@ -354,14 +344,14 @@ class TestGit:
pass
def test_init_created_repo(self):
assert self.capsule.files.exists("/home/wrenn-user/.git")
assert self.capsule.files.exists("/root/.git")
def test_status_clean(self):
status = self.capsule.git.status()
assert status.branch == "main"
def test_add_and_commit(self):
self.capsule.files.write("/home/wrenn-user/hello.txt", "hello git")
self.capsule.files.write("/root/hello.txt", "hello git")
self.capsule.git.add(all=True)
result = self.capsule.git.commit("initial commit")
assert result.exit_code == 0
@ -371,14 +361,14 @@ class TestGit:
assert status.is_clean
def test_status_with_changes(self):
self.capsule.files.write("/home/wrenn-user/dirty.txt", "uncommitted")
self.capsule.files.write("/root/dirty.txt", "uncommitted")
try:
status = self.capsule.git.status()
assert not status.is_clean
paths = [f.path for f in status.files]
assert "dirty.txt" in paths
finally:
self.capsule.files.remove("/home/wrenn-user/dirty.txt")
self.capsule.files.remove("/root/dirty.txt")
def test_branches(self):
branches = self.capsule.git.branches()
@ -416,124 +406,3 @@ class TestGit:
def test_get_config_missing_returns_none(self):
value = self.capsule.git.get_config("nonexistent.key")
assert value is None
class TestStatsUsageMetrics:
"""Account-scoped + per-capsule observability endpoints."""
def setup_method(self):
_ensure_env()
def test_stats_default(self):
with WrennClient() as client:
stats = client.capsules.stats()
assert isinstance(stats, CapsuleStats)
# current.running_count is always present even when zero
assert stats.current is not None
assert stats.peaks is not None
def test_stats_range(self):
with WrennClient() as client:
stats = client.capsules.stats(range="24h")
assert isinstance(stats, CapsuleStats)
if stats.range is not None:
assert stats.range.value == "24h"
def test_usage_no_args(self):
with WrennClient() as client:
usage = client.capsules.usage()
assert isinstance(usage, UsageResponse)
assert usage.points is not None
def test_usage_date_range(self):
from datetime import date, timedelta
today = date.today()
with WrennClient() as client:
usage = client.capsules.usage(from_=today - timedelta(days=7), to=today)
assert isinstance(usage, UsageResponse)
def test_metrics_running_capsule(self):
capsule = Capsule(wait=True)
try:
with WrennClient() as client:
m = client.capsules.metrics(capsule.capsule_id)
assert isinstance(m, CapsuleMetrics)
assert m.sandbox_id == capsule.capsule_id
assert m.points is not None
finally:
capsule.destroy()
def test_metrics_range_2h(self):
capsule = Capsule(wait=True)
try:
with WrennClient() as client:
m = client.capsules.metrics(capsule.capsule_id, range="2h")
assert isinstance(m, CapsuleMetrics)
finally:
capsule.destroy()
def test_metrics_destroyed_capsule_raises(self):
# Create, destroy, then ask for metrics — guaranteed valid-shape ID
# that no longer exists. Server returns 404 for the missing capsule
# (avoids the 400 validation path for malformed IDs).
capsule = Capsule(wait=True)
capsule_id = capsule.capsule_id
capsule.destroy(wait=True)
with WrennClient() as client:
with pytest.raises(WrennNotFoundError):
client.capsules.metrics(capsule_id)
class TestEventsStream:
"""SSE lifecycle stream — drives a capsule create and watches for it."""
def setup_method(self):
_ensure_env()
def test_stream_receives_capsule_create_event(self):
import threading
seen: list[SSEEvent] = []
ready = threading.Event()
stop = threading.Event()
def _reader() -> None:
with WrennClient() as client:
stream = client.events.stream()
ready.set()
for ev in stream:
seen.append(ev)
if stop.is_set() or len(seen) > 20:
return
t = threading.Thread(target=_reader, daemon=True)
t.start()
assert ready.wait(timeout=5)
# Give the server a beat to fully open the SSE stream before
# the action that should appear on it.
time.sleep(0.5)
capsule = Capsule()
capsule_id = capsule.capsule_id
try:
capsule.wait_ready(timeout=60)
finally:
capsule.destroy()
# Wait for either our capsule's event or a timeout.
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
if any(
ev.resource is not None and ev.resource.id == capsule_id for ev in seen
):
break
time.sleep(0.5)
stop.set()
assert any(isinstance(ev, SSEEvent) for ev in seen)
# Best-effort: assert we saw a capsule.* event for our capsule.
matching = [ev for ev in seen if ev.resource and ev.resource.id == capsule_id]
assert matching, f"no events for {capsule_id} in {len(seen)} received"

View File

@ -75,7 +75,7 @@ class TestCommandEnvironment:
def test_default_cwd_is_home(self):
result = self.capsule.commands.run("pwd")
assert result.stdout.strip() == "/home/wrenn-user"
assert result.stdout.strip() == "/root"
def test_cwd_resolves_relative_paths(self):
self.capsule.files.make_dir("/tmp/cwd_probe/sub")
@ -90,7 +90,7 @@ class TestCommandEnvironment:
# 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"
assert result.stdout.strip() == "/root"
def test_single_env_var(self):
result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"})
@ -115,29 +115,9 @@ class TestCommandEnvironment:
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 lines[0] == "/root"
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
@ -163,7 +143,7 @@ class TestLongRunningCommands:
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
"apt-get update -qq && apt-get install -y -qq cowsay", timeout=300
)
assert result.exit_code == 0
@ -408,9 +388,7 @@ class TestGitClone:
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
)
cls.capsule.git.clone(WRENN_REPO, "/root/wrenn", depth=1, timeout=300)
@classmethod
def teardown_class(cls):
@ -420,74 +398,66 @@ class TestGitClone:
pass
def test_clone_created_repo(self):
assert self.capsule.files.exists("/home/wrenn-user/wrenn/.git")
assert self.capsule.files.exists("/root/wrenn/.git")
def test_clone_checked_out_files(self):
entries = self.capsule.files.list("/home/wrenn-user/wrenn")
entries = self.capsule.files.list("/root/wrenn")
names = [e.name for e in entries]
assert "README.md" in names
def test_status_of_clone_is_clean(self):
status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
status = self.capsule.git.status(cwd="/root/wrenn")
assert status.branch == "main"
assert status.is_clean
def test_branches_lists_main(self):
branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn")
branches = self.capsule.git.branches(cwd="/root/wrenn")
names = [b.name for b in branches]
assert "main" in names
assert any(b.is_current for b in branches)
def test_remote_get_origin(self):
url = self.capsule.git.remote_get("origin", cwd="/home/wrenn-user/wrenn")
url = self.capsule.git.remote_get("origin", cwd="/root/wrenn")
assert url is not None
assert "wrennhq/wrenn" in url
def test_git_log_has_commit(self):
result = self.capsule.commands.run(
"git log --oneline -1", cwd="/home/wrenn-user/wrenn"
)
result = self.capsule.commands.run("git log --oneline -1", cwd="/root/wrenn")
assert result.exit_code == 0
assert result.stdout.strip()
def test_modify_add_commit(self):
marker = uuid.uuid4().hex
self.capsule.git.configure_user(
"CI Bot", "ci@example.com", cwd="/home/wrenn-user/wrenn", scope="local"
"CI Bot", "ci@example.com", cwd="/root/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")
self.capsule.files.write(f"/root/wrenn/sdk_probe_{marker}.txt", marker)
self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/root/wrenn")
staged = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
staged = self.capsule.git.status(cwd="/root/wrenn")
assert staged.has_staged
result = self.capsule.git.commit("probe commit", cwd="/home/wrenn-user/wrenn")
result = self.capsule.git.commit("probe commit", cwd="/root/wrenn")
assert result.exit_code == 0
after = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
after = self.capsule.git.status(cwd="/root/wrenn")
assert after.is_clean
assert after.ahead >= 1
def test_create_and_checkout_branch_in_clone(self):
self.capsule.git.create_branch("sdk-feature", cwd="/home/wrenn-user/wrenn")
branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn")
self.capsule.git.create_branch("sdk-feature", cwd="/root/wrenn")
branches = self.capsule.git.branches(cwd="/root/wrenn")
current = [b for b in branches if b.is_current]
assert current and current[0].name == "sdk-feature"
self.capsule.git.checkout_branch("main", cwd="/home/wrenn-user/wrenn")
self.capsule.git.checkout_branch("main", cwd="/root/wrenn")
def test_diff_via_commands(self):
self.capsule.files.write("/home/wrenn-user/wrenn/README.md", "overwritten\n")
self.capsule.files.write("/root/wrenn/README.md", "overwritten\n")
try:
result = self.capsule.commands.run(
"git diff --stat", cwd="/home/wrenn-user/wrenn"
)
result = self.capsule.commands.run("git diff --stat", cwd="/root/wrenn")
assert "README.md" in result.stdout
finally:
self.capsule.git.restore(
["README.md"], worktree=True, cwd="/home/wrenn-user/wrenn"
)
self.capsule.git.restore(["README.md"], worktree=True, cwd="/root/wrenn")
class TestGitErrors:
@ -511,7 +481,7 @@ class TestGitErrors:
with pytest.raises(GitError):
self.capsule.git.clone(
"https://github.com/wrennhq/this-repo-does-not-exist-xyz",
"/home/wrenn-user/missing",
"/root/missing",
timeout=120,
)
@ -523,11 +493,7 @@ class TestGitErrors:
def test_clone_with_branch(self):
self.capsule.git.clone(
WRENN_REPO,
"/home/wrenn-user/wrenn-main",
branch="main",
depth=1,
timeout=300,
WRENN_REPO, "/root/wrenn-main", branch="main", depth=1, timeout=300
)
status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn-main")
status = self.capsule.git.status(cwd="/root/wrenn-main")
assert status.branch == "main"

437
uv.lock generated
View File

@ -2,8 +2,7 @@ version = 1
revision = 3
requires-python = ">=3.13"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version == '3.14.*'",
"python_full_version >= '3.14'",
"python_full_version < '3.14'",
]
@ -37,49 +36,9 @@ 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" },
]
[[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]]
name = "black"
version = "26.5.1"
version = "26.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@ -89,28 +48,28 @@ dependencies = [
{ name = "platformdirs" },
{ name = "pytokens" },
]
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" }
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" }
wheels = [
{ 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/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/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/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/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/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/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/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/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/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/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" },
{ 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/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/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/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/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/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/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/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/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/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/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" },
]
[[package]]
name = "certifi"
version = "2026.5.20"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
@ -181,14 +140,14 @@ wheels = [
[[package]]
name = "click"
version = "8.4.0"
version = "8.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@ -242,7 +201,7 @@ wheels = [
[[package]]
name = "datamodel-code-generator"
version = "0.57.0"
version = "0.56.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argcomplete" },
@ -254,9 +213,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "pyyaml" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[package.optional-dependencies]
@ -285,6 +244,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "docspec"
version = "2.2.1"
@ -319,6 +287,19 @@ version = "0.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/ce/5d6a3782b9f88097ce3e579265015db3372ae78d12f67629b863a9208c96/docstring_parser-0.11.tar.gz", hash = "sha256:93b3f8f481c7d24e37c5d9f30293c89e2933fa209421c8abd731dd3ef0715ecb", size = 22775, upload-time = "2021-09-30T07:44:10.288Z" }
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]]
name = "filelock"
version = "3.29.0"
@ -400,11 +381,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.15"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@ -452,49 +433,49 @@ wheels = [
[[package]]
name = "librt"
version = "0.11.0"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
]
[[package]]
@ -551,48 +532,47 @@ wheels = [
[[package]]
name = "more-itertools"
version = "11.0.2"
version = "11.0.1"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
name = "mypy"
version = "2.1.0"
version = "1.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ast-serialize" },
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
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" }
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" }
wheels = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" },
]
[[package]]
@ -646,20 +626,20 @@ wheels = [
[[package]]
name = "packaging"
version = "26.2"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@ -698,7 +678,7 @@ wheels = [
[[package]]
name = "pydantic"
version = "2.13.4"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
@ -706,65 +686,62 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
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" }
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" }
wheels = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
]
[[package]]
@ -831,15 +808,15 @@ wheels = [
[[package]]
name = "python-discovery"
version = "1.3.1"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
{ name = "platformdirs" },
]
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" }
sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/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" },
{ url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" },
]
[[package]]
@ -904,7 +881,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.34.2"
version = "2.33.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@ -912,9 +889,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@ -931,27 +908,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.13"
version = "0.15.10"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
]
[[package]]
@ -1013,14 +990,14 @@ wheels = [
[[package]]
name = "typeguard"
version = "4.5.2"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@ -1046,16 +1023,16 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.7.0"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "virtualenv"
version = "21.3.3"
version = "21.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
@ -1063,9 +1040,9 @@ dependencies = [
{ name = "platformdirs" },
{ name = "python-discovery" },
]
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" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/8b/6331f7a7fe70131c301106ec1e7cf23e2501bf7d4ca3636805801ca191bb/virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e", size = 7614069, upload-time = "2026-04-27T17:05:58.927Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" },
{ url = "https://files.pythonhosted.org/packages/4b/eb/03bfb1299d4c4510329e470f13f9a4ce793df7fcb5a2fd3510f911066f61/virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", size = 7594690, upload-time = "2026-04-27T17:05:55.468Z" },
]
[[package]]
@ -1144,10 +1121,10 @@ wheels = [
[[package]]
name = "wrenn"
version = "0.3.0"
version = "0.1.4"
source = { editable = "." }
dependencies = [
{ name = "certifi" },
{ name = "email-validator" },
{ name = "httpx" },
{ name = "httpx-ws" },
{ name = "pydantic" },
@ -1161,14 +1138,13 @@ dev = [
{ name = "pydoc-markdown" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pyyaml" },
{ name = "respx" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "certifi", specifier = ">=2026.2.25" },
{ name = "email-validator", specifier = ">=2.3.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "httpx-ws", specifier = ">=0.9.0" },
{ name = "pydantic", specifier = ">=2.12.5" },
@ -1182,7 +1158,6 @@ dev = [
{ name = "pydoc-markdown", specifier = ">=4.8.2" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "respx", specifier = ">=0.23.1" },
{ name = "ruff", specifier = ">=0.15.10" },
]