Implement SAND-WP-0002 meta-framework foundation (T01–T09)

Add meta-framework spec, pydantic schemas, profile/extension YAML, extension
registry, ext.compose-ssh backend, SandboxManager with State Hub events, CLI
commands, integration docs, capability registry entry, and compose-e2e runbook.
Nine unit tests pass. T10 remote smoke test remains for operator.
This commit is contained in:
2026-06-22 23:27:31 +02:00
parent b0a57cf9d3
commit d6d3155792
28 changed files with 1796 additions and 15 deletions

View File

@@ -26,6 +26,16 @@ make build # uv build (wheel + sdist)
make cli-version # smoke test: sandboxer version
```
Sandbox CLI (v0):
```bash
sandboxer create --profile profile.compose-e2e --input repo=/path/to/repo
sandboxer get <id>
sandboxer list
sandboxer destroy <id>
sandboxer recreate <id>
```
Equivalent `uv` invocations without Make:
```bash

View File

@@ -90,6 +90,9 @@ sand-boxer stays inside the **sandboxing boundary**. Three sibling Coulomb
projects own adjacent concerns. Integration is contractual — they **request**
sandboxes; sand-boxer **establishes** them.
Per-sibling integration contracts: `docs/integrations/` (glas-harness,
wise-validator, snuggle-inventor).
### glas-harness — agent harness
**Owns:** Gateway, tool orchestration, skills, memory, channels, subagent

View File

@@ -0,0 +1,38 @@
# glas-harness integration
glas-harness owns the agent gateway, tools, memory, and channels. sand-boxer
delivers an isolated execution environment; the harness executes tools inside it.
## Example request
```bash
sandboxer create \
--profile profile.agent-dev \
--input repo=/path/to/workspace \
--actor agt \
--project glas-harness
```
## Response fields (ready state)
| Field | Owner | Description |
|-------|-------|-------------|
| `sandbox_id` | sand-boxer | Stable instance identifier |
| `reachability.ssh` | sand-boxer | SSH target for harness exec channel |
| `reachability.remote_dir` | sand-boxer | Workspace root on remote host |
| `state` | sand-boxer | Lifecycle state (`ready`, etc.) |
## Ownership
| Concern | Owner |
|---------|-------|
| Sandbox provision / teardown | sand-boxer |
| Tool call parsing and policies | glas-harness |
| SSH / tunnel reachability setup | glas-harness + ops-bridge |
| Agent memory and session state | glas-harness |
## Out of scope for sand-boxer
- Tool schemas and approval flows
- Channel bridges (Slack, email, etc.)
- Subagent orchestration

View File

@@ -0,0 +1,37 @@
# snuggle-inventor integration
snuggle-inventor owns code generation and modernization workflows. sand-boxer
provides a build sandbox with setup metadata resolved at the provision boundary.
## Example request
```bash
sandboxer create \
--profile profile.build \
--input repo=/path/to/target \
--actor agt \
--project snuggle-inventor
```
## Response fields (ready state)
| Field | Owner | Description |
|-------|-------|-------------|
| `sandbox_id` | sand-boxer | Build environment instance |
| `reachability.ssh` | sand-boxer | Remote workspace access |
| `profile.setup.secret_refs` | sand-boxer resolves | Secrets never returned to agent context |
## Ownership
| Concern | Owner |
|---------|-------|
| Sandbox provision and teardown | sand-boxer |
| Setup instructions content (Blitzy-style) | snuggle-inventor |
| Generated code and PR output | snuggle-inventor |
| Secret resolution at boundary | sand-boxer (via ops-warden / OpenBao) |
## Out of scope for sand-boxer
- Code generation prompts and tech specs
- AAP-style planning
- PR creation and review loops

View File

@@ -0,0 +1,40 @@
# wise-validator integration
wise-validator owns e2e test orchestration, health check semantics, and pass/fail
interpretation. sand-boxer delivers the compose environment; wise-validator runs
the validation story on top.
## Example request
```bash
sandboxer create \
--profile profile.compose-e2e \
--input repo=/path/to/repo \
--actor atm \
--project wise-validator
```
## Response fields (ready state)
| Field | Owner | Description |
|-------|-------|-------------|
| `sandbox_id` | sand-boxer | Instance id for the validation run |
| `reachability.ssh` | sand-boxer | SSH endpoint for test execution |
| `reachability.compose_project` | sand-boxer | Docker compose project name |
| `reachability.remote_dir` | sand-boxer | Synced repo path on remote host |
## Ownership
| Concern | Owner |
|---------|-------|
| Environment provision (rsync + compose up) | sand-boxer |
| `e2e/e2e.yml` parsing and semantics | wise-validator |
| HTTP health polling and timeouts | wise-validator |
| Test command execution and reporting | wise-validator |
| State Hub test result events | wise-validator |
## Out of scope for sand-boxer
- Running `test_command` from e2e.yml
- Interpreting health check pass/fail
- Posting validation results to State Hub

187
docs/meta-framework.md Normal file
View File

@@ -0,0 +1,187 @@
# sand-boxer meta-framework specification
Version 0.1 — derived from `research/03-meta-framework-synthesis.md` and `INTENT.md`.
sand-boxer is the **sandbox establishment service**: one API for consumers, many
extension backends. It provisions *where and how* code runs; sibling projects own
agent harnessing, validation, and code generation.
---
## Resource model
| Resource | Description |
|----------|-------------|
| **Profile** | Named, versioned sandbox recipe: extension binding, isolation, network, TTL, placement |
| **Extension** | Backend adapter implementing provision / wait_ready / teardown |
| **Host** | Registered placement target for self-hosted extensions |
| **Sandbox** | Running instance of a profile |
| **Snapshot** | Point-in-time workspace checkpoint (deferred — SAND-WP-0003) |
| **Route** | Extension selection policy when multiple backends qualify |
| **Meter** | Usage record for payments layer (SaaS extensions — SAND-WP-0006) |
---
## Lifecycle states
```
requested → provisioning → ready → active → { expired | failed } → destroying → destroyed
```
| State | Meaning |
|-------|---------|
| `requested` | Create accepted; not yet handed to extension |
| `provisioning` | Extension running provision + wait_ready |
| `ready` | Reachability confirmed; consumer may attach |
| `active` | Consumer has marked sandbox in use (optional transition) |
| `expired` | TTL elapsed before explicit destroy |
| `failed` | Provision or readiness failed |
| `destroying` | Teardown in progress |
| `destroyed` | Resources released; record retained for audit |
### State Hub event mapping
Each transition emits a State Hub progress event (or dedicated registration API
when available):
| Transition | `event_type` | Required fields |
|------------|--------------|-----------------|
| → `requested` | `note` | `sandbox_id`, `profile_id`, `consumer` |
| → `provisioning` | `note` | `extension_id`, `host` |
| → `ready` | `milestone` | `reachability` descriptor |
| → `active` | `note` | `actor_type`, timestamps |
| → `failed` | `note` | `error` summary |
| → `destroying` | `note` | — |
| → `destroyed` | `milestone` | `duration_s`, cleanup report |
Event `detail` payload (JSON):
```json
{
"sandbox_id": "abc12345",
"profile_id": "profile.compose-e2e",
"extension_id": "ext.compose-ssh",
"host": "coulombcore",
"consumer": {"actor": "atm", "project": "wise-validator", "run_id": "..."},
"actor_type": "atm",
"state": "ready",
"reachability": {"ssh": "root@coulombcore", "remote_dir": "/tmp/sandboxer/abc12345"},
"timestamps": {"created_at": "...", "ready_at": "..."}
}
```
Extends the `build-agent` self-register pattern: generic sandbox identities carry
`profile_id` + `extension_id` instead of build-machine metadata.
---
## Core API operations (v0)
| Operation | Description | v0 scope |
|-----------|-------------|----------|
| `create` | Provision from profile + inputs | **Yes** |
| `get` | Inspect sandbox status | **Yes** |
| `list` | List sandboxes (filter by consumer optional) | **Yes** |
| `extend_ttl` | Extend time-to-live | Stub |
| `recreate` | Destroy and reprovision from stored seed | **Yes** |
| `destroy` | Idempotent teardown | **Yes** |
| `snapshot` / `restore` | Checkpoint workspace | Deferred (SAND-WP-0003) |
| `exec` | Run command in sandbox | Harness-owned via SSH (glas-harness) |
HTTP surface (optional v0; CLI calls core library directly):
- `POST /v1/sandboxes` — create
- `GET /v1/sandboxes/{id}` — get
- `GET /v1/sandboxes` — list
- `DELETE /v1/sandboxes/{id}` — destroy
---
## Consumer attribution
Every create request carries a **consumer** block:
```yaml
consumer:
actor: adm | agt | atm
project: <calling-repo-or-service> # e.g. wise-validator, glas-harness
session_id: <optional>
run_id: <optional>
```
| Actor | Typical caller |
|-------|----------------|
| `adm` | Human operator via CLI |
| `agt` | LLM agent session |
| `atm` | Deterministic automation (CI, activity-core, wise-validator) |
sand-boxer records attribution on every lifecycle event. It does not interpret
agent intent or authorize the caller — flex-auth owns authorization when enforced.
---
## Extension interface
Each extension implements:
```text
provision(profile, inputs, placement) → SandboxHandle
wait_ready(handle) → Reachability
teardown(handle) → CleanupReport
estimate_cost?(profile, duration) → MeterQuote # optional; SaaS only
```
Registration requirements (validated at load time):
- `id` — unique extension identifier (`ext.<name>`)
- `capabilities` — isolation levels, regions, persistence, pricing model
- `handler` — Python entry point or built-in registry binding
Extensions are discovered from `extensions/*.yaml` at repo root and loaded via
`sandboxer.extensions.registry`.
---
## Routing policy vocabulary
When multiple extensions satisfy a profile capability:
| Strategy | Behavior |
|----------|----------|
| `prefer-self-hosted` | Self-hosted extensions first; SaaS fallback (default Coulomb posture) |
| `lowest-cost` | Cheapest `estimate_cost` quote wins |
| `lowest-latency` | Closest region / host wins |
| `explicit` | Profile names a single `extension`; no auto-routing |
v0 resolves `profile.extension` directly — routing engine deferred to SAND-WP-0006.
---
## Security limits
sand-boxer commits to:
1. **Default-deny network** unless profile explicitly allows egress
2. **Secrets at provision boundary**`secret_refs` resolved via ops-warden /
OpenBao; never returned to agent context
3. **Blast-radius isolation** — dedicated hosts (sandboxer01, CoulombCore) away
from Railiance01 production
4. **Observable lifecycle** — every transition attributed to `adm` / `agt` / `atm`
5. **Honest limits** — allowed tool paths can be abused by compromised agents
sand-boxer does **not** provide intent-aware egress filtering in v1.
---
## Out of scope (sibling ownership)
| Concern | Owner |
|---------|-------|
| Agent gateway, tools, memory | glas-harness |
| e2e.yml semantics, health checks, test pass/fail | wise-validator |
| Code generation, setup instructions content | snuggle-inventor |
| SSH tunnels | ops-bridge |
| SSH certificates | ops-warden |
| Workstream / task state | state-hub |
See `docs/integrations/` for per-sibling contracts.

View File

@@ -0,0 +1,90 @@
# Runbook: profile.compose-e2e
Provision a compose-based e2e sandbox via `ext.compose-ssh` (e2e-framework lineage).
## Prerequisites
**Workstation**
- `uv`, `ssh`, `rsync`
- sand-boxer installed: `make setup`
**Remote host** (CoulombCore or sandboxer01)
- SSH access (`SANDBOXER_HOST` or `--host`)
- Docker + compose plugin
- Sufficient disk for images
```bash
export SANDBOXER_HOST=<ip-or-hostname> # e.g. coulombcore IP
export SANDBOXER_SSH_USER=root # optional
export SANDBOXER_SSH_KEY=~/.ssh/id_rsa # optional
```
## Create
Target repo must contain `e2e/e2e.yml` (for compose file path) or a
`docker-compose*.yml` at repo root.
```bash
sandboxer create \
--profile profile.compose-e2e \
--input repo=/path/to/repo \
--actor adm \
--project sand-boxer
```
Confirm `state: ready` and note `reachability.ssh`, `reachability.remote_dir`,
and `reachability.compose_project`.
## Manual readiness check
sand-boxer confirms compose services are running (not HTTP health — that is
wise-validator's job):
```bash
ssh $SANDBOXER_SSH_USER@$SANDBOXER_HOST \
'docker compose -p <compose_project> -f <remote_dir>/<compose_file> ps'
```
## Destroy
```bash
sandboxer destroy <sandbox_id>
```
Verify cleanup:
```bash
ssh $SANDBOXER_SSH_USER@$SANDBOXER_HOST 'docker compose ls'
ssh $SANDBOXER_SSH_USER@$SANDBOXER_HOST 'ls /tmp/sandboxer/'
```
## Compatibility with legacy `make e2e`
Interim callers in `the-custodian` should migrate to sand-boxer for
provision/teardown only. Test execution remains in wise-validator (SAND-WP-0003).
| Legacy | sand-boxer |
|--------|------------|
| `make e2e REPO=foo` (full pipeline) | `sandboxer create` + wise-validator run |
| rsync + compose up | `ext.compose-ssh` provision |
| compose down + dir removal | `sandboxer destroy` |
## Troubleshooting
**Provision fails — no compose file**
Ensure repo has `e2e/e2e.yml` with `compose_file` or a root `docker-compose*.yml`.
**Leftover compose projects**
```bash
ssh $SANDBOXER_HOST 'docker compose -p sbx-e2e-<id> down -v; rm -rf /tmp/sandboxer/<id>'
```
**Skip State Hub events (local debug)**
```bash
export SANDBOXER_NO_STATE_HUB=1
```

29
extensions/README.md Normal file
View File

@@ -0,0 +1,29 @@
# Extensions
Backend adapters for sandbox establishment. Each extension is declared in
`ext.<name>.yaml` and implements `provision`, `wait_ready`, and `teardown`.
## ext.compose-ssh
Self-hosted extension migrated from `the-custodian/e2e-framework/`.
**Provision:** SSH to configured host → isolated directory per sandbox id → rsync
repo → `docker compose up` with unique project name.
**wait_ready:** Confirms compose services are running (`docker compose ps`). Does
not run HTTP health checks — wise-validator owns that.
**teardown:** `docker compose down -v` + remove remote directory (idempotent).
### Compatibility note for `make e2e` callers
Legacy `make e2e REPO=<repo>` runs provision + health + tests + teardown in one
step. sand-boxer splits responsibilities:
| Step | Owner |
|------|-------|
| rsync + compose up/down | sand-boxer (`ext.compose-ssh`) |
| health checks + test_command | wise-validator (SAND-WP-0003) |
Interim workflow: `sandboxer create --profile profile.compose-e2e --input repo=...`
then run validation separately until wise-validator migration lands.

View File

@@ -0,0 +1,16 @@
id: ext.compose-ssh
title: Compose over SSH
description: >
Self-hosted extension migrated from the-custodian/e2e-framework. Provisions an
isolated directory on a remote host, rsyncs the consumer repo, and runs docker
compose. Test execution and health polling are consumer-owned (wise-validator).
handler: sandboxer.extensions.compose_ssh:ComposeSSHExtension
capabilities:
isolation_levels: [container]
regions: []
persistence: false
pricing_model: self-hosted
config:
base_dir: /tmp/sandboxer
ssh_user: root
compose_timeout_s: 180

View File

@@ -0,0 +1,31 @@
id: profile.compose-e2e
version: "1.0.0"
extension: ext.compose-ssh
isolation:
level: container
network:
default: deny
egress: []
workspace:
mode: remote-canonical
access: rw
scope_default: session
ttl:
default: 4h
max: 24h
idle_reap: null
resources:
cpu: null
memory_mb: null
setup:
instructions: ""
secret_refs: []
placement:
prefer: [sandboxer01]
fallback: [coulombcore]
reachability:
tunnel: ops-bridge
identity: ops-warden
metadata:
cost_class: self-hosted
latency_class: standard

View File

@@ -11,6 +11,9 @@ requires-python = ">=3.11"
license = "MIT-0"
dependencies = [
"typer>=0.12",
"pydantic>=2.0",
"pyyaml>=6.0",
"httpx>=0.27",
]
[project.scripts]

View File

@@ -0,0 +1,45 @@
---
id: capability.execution.sandbox-provision
name: Sandbox Provisioning
summary: Isolated execution environments for agentic development, e2e testing, and bounded automations — profile-based provision, TTL teardown, and State Hub lifecycle registration.
owner: sand-boxer
status: draft
domain: infotech
tags: [sandbox, isolation, provision, e2e, agentic, execution, profile]
maturity:
discovery:
current: D4
target: D6
confidence: high
rationale: >
Charter (INTENT.md), meta-framework spec (docs/meta-framework.md), and
research synthesis define scope. First extension (ext.compose-ssh) in progress.
availability:
current: A2
target: A5
confidence: medium
rationale: >
CLI v0 and ext.compose-ssh scaffold land in SAND-WP-0002. SaaS extensions
and payments deferred.
external_evidence:
completeness:
level: C2
name: Partial
confidence: medium
basis: scope_vs_intent_and_consumer_expectations
satisfied_expectations:
- profile-based create/destroy via CLI
- State Hub lifecycle events on transitions
broken_expectations:
- SaaS extensions and payments layer not yet built
- wise-validator migration not complete
out_of_scope_expectations:
- agent harness and tool orchestration (glas-harness)
- e2e test semantics (wise-validator)
consumption_modes:
- CLI (sandboxer)
- core library (Python)
- HTTP API (planned)

View File

@@ -1,4 +1,23 @@
version: 1
updated: '2026-06-16'
domain: helix_forge
capabilities: []
updated: '2026-06-22'
domain: infotech
capabilities:
- id: capability.execution.sandbox-provision
name: Sandbox Provisioning
summary: Isolated execution environments for agentic development, e2e testing, and bounded automations — profile-based provision, TTL teardown, and State Hub lifecycle registration.
vector: D4 / A2 / C2 / R1
domain: infotech
status: draft
owner: sand-boxer
path: registry/capabilities/execution.sandbox-provision.md
tags:
- sandbox
- isolation
- provision
- e2e
- agentic
- execution
- profile
consumption_modes:
- CLI
- core library

View File

@@ -1,8 +1,15 @@
"""CLI entry point (bootstrap stub; expanded in SAND-WP-0002)."""
"""sand-boxer CLI — sandbox establishment surface (v0)."""
from __future__ import annotations
import json
from typing import Annotated
import typer
from sandboxer import __version__
from sandboxer.core.manager import SandboxManager
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest
app = typer.Typer(
name="sandboxer",
@@ -22,5 +29,90 @@ def version() -> None:
typer.echo(__version__)
def _parse_inputs(values: list[str]) -> dict[str, str]:
inputs: dict[str, str] = {}
for item in values:
if "=" not in item:
raise typer.BadParameter(f"Expected key=value, got: {item}")
key, val = item.split("=", 1)
inputs[key] = val
return inputs
def _print_status(status: object) -> None:
typer.echo(json.dumps(status, default=str, indent=2))
@app.command("create")
def sandbox_create(
profile: Annotated[str, typer.Option("--profile", help="Profile id")],
input: Annotated[
list[str] | None,
typer.Option("--input", help="Input key=value (repeatable)"),
] = None,
actor: Annotated[str, typer.Option(help="Consumer actor type")] = "adm",
project: Annotated[str, typer.Option(help="Calling project id")] = "sand-boxer",
host: Annotated[str | None, typer.Option(help="Override placement host")] = None,
) -> None:
"""Provision a sandbox from a profile."""
request = SandboxCreateRequest(
profile=profile,
inputs=_parse_inputs(input or []),
consumer=Consumer(actor=ActorType(actor), project=project),
)
manager = SandboxManager()
try:
status = manager.create(request, host=host)
except Exception as exc:
typer.echo(f"Error: {exc}", err=True)
raise typer.Exit(code=1) from exc
_print_status(status.model_dump(mode="json"))
@app.command("get")
def sandbox_get(sandbox_id: str) -> None:
"""Get sandbox status by id."""
status = SandboxManager().get(sandbox_id)
if not status:
typer.echo(f"Sandbox not found: {sandbox_id}", err=True)
raise typer.Exit(code=1)
_print_status(status.model_dump(mode="json"))
@app.command("list")
def sandbox_list(
state: Annotated[str | None, typer.Option(help="Filter by state")] = None,
) -> None:
"""List known sandboxes."""
items = SandboxManager().list()
if state:
items = [s for s in items if s.state.value == state]
_print_status([s.model_dump(mode="json") for s in items])
@app.command("destroy")
def sandbox_destroy(sandbox_id: str) -> None:
"""Destroy a sandbox (idempotent)."""
manager = SandboxManager()
try:
status = manager.destroy(sandbox_id)
except KeyError as exc:
typer.echo(str(exc), err=True)
raise typer.Exit(code=1) from exc
_print_status(status.model_dump(mode="json"))
@app.command("recreate")
def sandbox_recreate(sandbox_id: str) -> None:
"""Destroy and reprovision from stored inputs."""
manager = SandboxManager()
try:
status = manager.recreate(sandbox_id)
except (KeyError, Exception) as exc:
typer.echo(f"Error: {exc}", err=True)
raise typer.Exit(code=1) from exc
_print_status(status.model_dump(mode="json"))
if __name__ == "__main__":
app()

View File

@@ -0,0 +1 @@
"""Core sandbox establishment logic."""

View File

@@ -0,0 +1,122 @@
"""Core sandbox establishment logic — harness-agnostic."""
from __future__ import annotations
from sandboxer.extensions.registry import load_extension, resolve_backend
from sandboxer.lifecycle.state_hub import emit_lifecycle_event, event_type_for_state
from sandboxer.lifecycle.store import SandboxStore, utcnow
from sandboxer.models import (
Reachability,
SandboxCreateRequest,
SandboxState,
SandboxStatus,
)
from sandboxer.placement import resolve_host
from sandboxer.profiles.loader import load_profile
class SandboxManager:
def __init__(self, store: SandboxStore | None = None) -> None:
self.store = store or SandboxStore()
def create(self, request: SandboxCreateRequest, *, host: str | None = None) -> SandboxStatus:
profile = load_profile(request.profile)
extension = load_extension(profile.extension)
backend = resolve_backend(extension)
resolved_host = resolve_host(profile, override=host)
now = utcnow()
status = SandboxStatus(
sandbox_id="pending",
profile_id=profile.id,
extension_id=extension.id,
state=SandboxState.REQUESTED,
consumer=request.consumer,
host=resolved_host,
inputs=dict(request.inputs),
created_at=now,
updated_at=now,
)
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
status.state = SandboxState.PROVISIONING
status.updated_at = utcnow()
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
try:
handle = backend.provision(profile, request.inputs, resolved_host)
status.sandbox_id = handle["sandbox_id"]
status.inputs["compose_file"] = handle.get("compose_file", "")
status.inputs["ssh_user"] = handle.get("ssh_user", "")
reach = backend.wait_ready(handle)
status.reachability = Reachability(**reach)
status.state = SandboxState.READY
status.ready_at = utcnow()
status.updated_at = status.ready_at
self.store.save(status)
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
return status
except Exception as exc:
status.state = SandboxState.FAILED
status.error = str(exc)
status.updated_at = utcnow()
if status.sandbox_id != "pending":
self.store.save(status)
emit_lifecycle_event(
status,
summary=f"Sandbox provision failed: {exc}",
event_type=event_type_for_state(status.state),
)
raise
def get(self, sandbox_id: str) -> SandboxStatus | None:
return self.store.get(sandbox_id)
def list(self) -> list[SandboxStatus]:
return sorted(self.store.list_all(), key=lambda s: s.created_at, reverse=True)
def destroy(self, sandbox_id: str) -> SandboxStatus:
status = self.store.get(sandbox_id)
if not status:
raise KeyError(f"Sandbox not found: {sandbox_id}")
if status.state == SandboxState.DESTROYED:
return status
profile = load_profile(status.profile_id)
extension = load_extension(profile.extension)
backend = resolve_backend(extension)
status.state = SandboxState.DESTROYING
status.updated_at = utcnow()
self.store.save(status)
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
handle = {
"sandbox_id": status.sandbox_id,
"host": status.host or "",
"remote_dir": status.reachability.remote_dir if status.reachability else "",
"compose_project": status.reachability.compose_project if status.reachability else "",
"compose_file": status.inputs.get("compose_file", ""),
"ssh_user": status.inputs.get("ssh_user", ""),
}
backend.teardown(handle)
status.state = SandboxState.DESTROYED
status.destroyed_at = utcnow()
status.updated_at = status.destroyed_at
self.store.save(status)
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
return status
def recreate(self, sandbox_id: str) -> SandboxStatus:
existing = self.store.get(sandbox_id)
if not existing:
raise KeyError(f"Sandbox not found: {sandbox_id}")
request = SandboxCreateRequest(
profile=existing.profile_id,
inputs=dict(existing.inputs),
consumer=existing.consumer,
)
if existing.state != SandboxState.DESTROYED:
self.destroy(sandbox_id)
return self.create(request, host=existing.host)

View File

@@ -0,0 +1,123 @@
"""ext.compose-ssh — SSH + docker compose provisioning (e2e-framework lineage)."""
from __future__ import annotations
import uuid
from pathlib import Path
from typing import Any
import yaml
from sandboxer.extensions.ssh import SSHConfig
from sandboxer.models import Profile
class ComposeSSHExtension:
"""Provision isolated compose stacks on a remote host via SSH."""
def __init__(self, config: dict[str, Any] | None = None) -> None:
cfg = config or {}
self.base_dir: str = cfg.get("base_dir", "/tmp/sandboxer")
self.ssh_user: str = cfg.get("ssh_user", "root")
self.compose_timeout_s: int = int(cfg.get("compose_timeout_s", 180))
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
repo = inputs.get("repo")
if not repo:
raise ValueError("inputs.repo is required for profile.compose-e2e")
repo_path = Path(repo).expanduser().resolve()
if not repo_path.exists():
raise FileNotFoundError(f"Repo path does not exist: {repo_path}")
sandbox_id = inputs.get("sandbox_id") or str(uuid.uuid4())[:8]
remote_dir = f"{self.base_dir}/{sandbox_id}"
ssh = SSHConfig.from_env(host, user=self.ssh_user)
rc, out = ssh.run(f"mkdir -p {remote_dir}")
if rc != 0:
raise RuntimeError(f"Failed to create remote dir: {out}")
ssh.rsync(repo_path, remote_dir)
compose_file = self._resolve_compose_file(repo_path)
project_name = f"sbx-{profile.id.split('.')[-1]}-{sandbox_id}"
up_cmd = (
f"cd {remote_dir} && "
f"docker compose -p {project_name} -f {compose_file} up -d"
)
rc, out = ssh.run(up_cmd, timeout=self.compose_timeout_s)
if rc != 0:
raise RuntimeError(f"docker compose up failed: {out}")
return {
"sandbox_id": sandbox_id,
"host": host,
"remote_dir": remote_dir,
"compose_project": project_name,
"compose_file": compose_file,
"repo": str(repo_path),
"ssh_user": ssh.user,
}
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
"""Confirm compose services are running (no HTTP health polling)."""
ssh = SSHConfig.from_env(handle["host"], user=handle.get("ssh_user", self.ssh_user))
project = handle["compose_project"]
remote_dir = handle["remote_dir"]
compose_file = handle["compose_file"]
cmd = (
f"cd {remote_dir} && "
f"docker compose -p {project} -f {compose_file} ps --status running -q"
)
rc, out = ssh.run(cmd, timeout=60)
if rc != 0 or not out.strip():
raise RuntimeError(f"compose services not running: {out}")
return {
"ssh": ssh.target,
"remote_dir": remote_dir,
"compose_project": project,
"host": handle["host"],
}
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
ssh = SSHConfig.from_env(handle["host"], user=handle.get("ssh_user", self.ssh_user))
project = handle.get("compose_project")
remote_dir = handle.get("remote_dir")
compose_file = handle.get("compose_file")
cleaned_compose = False
if project and remote_dir and compose_file:
down_cmd = (
f"cd {remote_dir} && "
f"docker compose -p {project} -f {compose_file} "
f"down -v --remove-orphans 2>&1 || true"
)
ssh.run(down_cmd, timeout=60)
cleaned_compose = True
cleaned_dir = False
if remote_dir:
rc, _ = ssh.run(f"rm -rf {remote_dir}", timeout=30)
cleaned_dir = rc == 0
return {
"compose_removed": str(cleaned_compose),
"remote_dir_removed": str(cleaned_dir),
"remote_dir": remote_dir or "",
}
def _resolve_compose_file(self, repo_path: Path) -> str:
e2e_yml = repo_path / "e2e" / "e2e.yml"
if e2e_yml.exists():
raw = yaml.safe_load(e2e_yml.read_text())
if raw and raw.get("compose_file"):
return raw["compose_file"]
for candidate in ("docker-compose.dev.yml", "docker-compose.yml", "compose.yml"):
if (repo_path / candidate).exists():
return candidate
raise FileNotFoundError(
f"No compose file found in {repo_path} (expected e2e/e2e.yml or docker-compose*.yml)"
)

View File

@@ -0,0 +1,74 @@
"""Extension discovery, validation, and handler resolution."""
from __future__ import annotations
import importlib
from pathlib import Path
from typing import Protocol
import yaml
from sandboxer.models import Extension, Profile
_REPO_ROOT = Path(__file__).resolve().parents[3]
_EXTENSIONS_DIR = _REPO_ROOT / "extensions"
_REQUIRED_CAPABILITY_FIELDS = ("isolation_levels", "pricing_model")
class ExtensionBackend(Protocol):
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]: ...
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]: ...
def teardown(self, handle: dict[str, str]) -> dict[str, str]: ...
def extensions_dir() -> Path:
return _EXTENSIONS_DIR
def _validate_extension_caps(ext: Extension) -> None:
caps = ext.capabilities
for field in _REQUIRED_CAPABILITY_FIELDS:
if not getattr(caps, field, None):
raise ValueError(f"Extension {ext.id} missing capability field: {field}")
if not ext.handler:
raise ValueError(f"Extension {ext.id} missing handler")
def load_extension(extension_id: str, *, extensions_root: Path | None = None) -> Extension:
root = extensions_root or _EXTENSIONS_DIR
path = root / f"{extension_id}.yaml"
if not path.exists():
raise FileNotFoundError(f"Extension not found: {extension_id} ({path})")
raw = yaml.safe_load(path.read_text())
ext = Extension.model_validate(raw)
if ext.id != extension_id:
raise ValueError(f"Extension id mismatch: file {extension_id}, content {ext.id}")
_validate_extension_caps(ext)
return ext
def load_all_extensions(*, extensions_root: Path | None = None) -> dict[str, Extension]:
root = extensions_root or _EXTENSIONS_DIR
extensions: dict[str, Extension] = {}
if not root.exists():
return extensions
for path in sorted(root.glob("*.yaml")):
ext = load_extension(path.stem, extensions_root=root)
if ext.id in extensions:
raise ValueError(f"Duplicate extension id: {ext.id}")
extensions[ext.id] = ext
return extensions
def resolve_backend(extension: Extension) -> ExtensionBackend:
module_path, _, attr = extension.handler.partition(":")
if not attr:
raise ValueError(f"Invalid handler for {extension.id}: {extension.handler}")
module = importlib.import_module(module_path)
cls = getattr(module, attr)
return cls(extension.config)

View File

@@ -0,0 +1,71 @@
"""SSH and rsync helpers for self-hosted extensions."""
from __future__ import annotations
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
@dataclass
class SSHConfig:
host: str
user: str = "root"
key: str | None = None
connect_timeout: int = 15
@property
def target(self) -> str:
return f"{self.user}@{self.host}"
@classmethod
def from_env(cls, host: str, *, user: str | None = None, key: str | None = None) -> SSHConfig:
return cls(
host=host,
user=user or os.environ.get("SANDBOXER_SSH_USER", "root"),
key=key or os.environ.get("SANDBOXER_SSH_KEY"),
)
def ssh_base(self) -> list[str]:
args = [
"ssh",
"-o",
"StrictHostKeyChecking=no",
"-o",
"BatchMode=yes",
"-o",
f"ConnectTimeout={self.connect_timeout}",
]
if self.key:
args += ["-i", self.key]
args.append(self.target)
return args
def run(self, cmd: str, *, timeout: int = 60) -> tuple[int, str]:
result = subprocess.run(
self.ssh_base() + [cmd],
capture_output=True,
text=True,
timeout=timeout,
)
return result.returncode, result.stdout + result.stderr
def rsync(self, local_path: Path, remote_dir: str, *, timeout: int = 120) -> None:
rsync_args = [
"rsync",
"-az",
"--delete",
"--exclude=.git",
"--exclude=__pycache__",
"--exclude=*.pyc",
"--exclude=.venv",
"--exclude=node_modules",
]
ssh_cmd = "ssh -o StrictHostKeyChecking=no"
if self.key:
ssh_cmd = f"ssh -i {self.key} -o StrictHostKeyChecking=no"
rsync_args += ["-e", ssh_cmd, f"{local_path}/", f"{self.target}:{remote_dir}/"]
result = subprocess.run(rsync_args, capture_output=True, text=True, timeout=timeout)
if result.returncode != 0:
raise RuntimeError(f"rsync failed: {result.stdout + result.stderr}")

View File

@@ -0,0 +1,62 @@
"""State Hub lifecycle event emission."""
from __future__ import annotations
import os
from typing import Any
import httpx
from sandboxer.models import SandboxState, SandboxStatus
_DEFAULT_HUB = "http://127.0.0.1:8000"
def hub_url() -> str:
return os.environ.get("STATE_HUB_URL", _DEFAULT_HUB)
def emit_lifecycle_event(
status: SandboxStatus,
*,
summary: str | None = None,
event_type: str = "note",
author: str = "sandboxer",
) -> dict[str, Any] | None:
if os.environ.get("SANDBOXER_NO_STATE_HUB", "").lower() in ("1", "true", "yes"):
return None
payload = {
"event_type": event_type,
"summary": summary or f"Sandbox {status.sandbox_id}{status.state.value}",
"author": author,
"detail": {
"sandbox_id": status.sandbox_id,
"profile_id": status.profile_id,
"extension_id": status.extension_id,
"host": status.host,
"consumer": status.consumer.model_dump(),
"actor_type": status.consumer.actor.value,
"state": status.state.value,
"reachability": status.reachability.model_dump() if status.reachability else None,
"timestamps": {
"created_at": status.created_at.isoformat(),
"updated_at": status.updated_at.isoformat(),
"ready_at": status.ready_at.isoformat() if status.ready_at else None,
"destroyed_at": status.destroyed_at.isoformat() if status.destroyed_at else None,
},
},
}
try:
response = httpx.post(f"{hub_url()}/progress/", json=payload, timeout=10.0)
response.raise_for_status()
return response.json()
except httpx.HTTPError:
return None
def event_type_for_state(state: SandboxState) -> str:
if state in (SandboxState.READY, SandboxState.DESTROYED):
return "milestone"
return "note"

View File

@@ -0,0 +1,52 @@
"""Persistent sandbox status store (JSON file)."""
from __future__ import annotations
import json
import os
from datetime import UTC, datetime
from pathlib import Path
from sandboxer.models import SandboxStatus
def _default_store_path() -> Path:
base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
return base / "sandboxer" / "sandboxes.json"
class SandboxStore:
def __init__(self, path: Path | None = None) -> None:
self.path = path or _default_store_path()
self.path.parent.mkdir(parents=True, exist_ok=True)
def _read(self) -> dict[str, dict]:
if not self.path.exists():
return {}
return json.loads(self.path.read_text())
def _write(self, data: dict[str, dict]) -> None:
self.path.write_text(json.dumps(data, indent=2, default=str))
def save(self, status: SandboxStatus) -> None:
data = self._read()
data[status.sandbox_id] = status.model_dump(mode="json")
self._write(data)
def get(self, sandbox_id: str) -> SandboxStatus | None:
raw = self._read().get(sandbox_id)
if not raw:
return None
return SandboxStatus.model_validate(raw)
def list_all(self) -> list[SandboxStatus]:
return [SandboxStatus.model_validate(v) for v in self._read().values()]
def delete(self, sandbox_id: str) -> None:
data = self._read()
data.pop(sandbox_id, None)
self._write(data)
def utcnow() -> datetime:
return datetime.now(UTC)

147
src/sandboxer/models.py Normal file
View File

@@ -0,0 +1,147 @@
"""Pydantic schemas for profiles, extensions, and sandbox lifecycle."""
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from typing import Any, Literal
from pydantic import BaseModel, Field
class ActorType(StrEnum):
ADM = "adm"
AGT = "agt"
ATM = "atm"
class SandboxState(StrEnum):
REQUESTED = "requested"
PROVISIONING = "provisioning"
READY = "ready"
ACTIVE = "active"
EXPIRED = "expired"
FAILED = "failed"
DESTROYING = "destroying"
DESTROYED = "destroyed"
class RouteStrategy(StrEnum):
PREFER_SELF_HOSTED = "prefer-self-hosted"
LOWEST_COST = "lowest-cost"
LOWEST_LATENCY = "lowest-latency"
EXPLICIT = "explicit"
class Consumer(BaseModel):
actor: ActorType
project: str
session_id: str | None = None
run_id: str | None = None
class IsolationSpec(BaseModel):
level: Literal["container", "microvm", "policy"] = "container"
class NetworkSpec(BaseModel):
default: Literal["deny", "allow"] = "deny"
egress: list[str] = Field(default_factory=list)
class WorkspaceSpec(BaseModel):
mode: Literal["mirror", "remote-canonical"] = "remote-canonical"
access: Literal["none", "ro", "rw"] = "rw"
class TtlSpec(BaseModel):
default: str = "4h"
max: str = "24h"
idle_reap: str | None = None
class ResourceSpec(BaseModel):
cpu: str | None = None
memory_mb: int | None = None
class SetupSpec(BaseModel):
instructions: str = ""
secret_refs: list[str] = Field(default_factory=list)
class PlacementSpec(BaseModel):
prefer: list[str] = Field(default_factory=list)
fallback: list[str] = Field(default_factory=list)
class ReachabilitySpec(BaseModel):
tunnel: str = "ops-bridge"
identity: str = "ops-warden"
class ProfileMetadata(BaseModel):
cost_class: Literal["self-hosted", "saas-metered"] = "self-hosted"
latency_class: str = "standard"
class Profile(BaseModel):
id: str
version: str
extension: str
isolation: IsolationSpec = Field(default_factory=IsolationSpec)
network: NetworkSpec = Field(default_factory=NetworkSpec)
workspace: WorkspaceSpec = Field(default_factory=WorkspaceSpec)
scope_default: Literal["session", "agent", "shared"] = "session"
ttl: TtlSpec = Field(default_factory=TtlSpec)
resources: ResourceSpec = Field(default_factory=ResourceSpec)
setup: SetupSpec = Field(default_factory=SetupSpec)
placement: PlacementSpec = Field(default_factory=PlacementSpec)
reachability: ReachabilitySpec = Field(default_factory=ReachabilitySpec)
metadata: ProfileMetadata = Field(default_factory=ProfileMetadata)
class ExtensionCapabilities(BaseModel):
isolation_levels: list[str] = Field(default_factory=lambda: ["container"])
regions: list[str] = Field(default_factory=list)
persistence: bool = False
pricing_model: Literal["self-hosted", "metered"] = "self-hosted"
class Extension(BaseModel):
id: str
title: str
description: str = ""
handler: str
capabilities: ExtensionCapabilities = Field(default_factory=ExtensionCapabilities)
config: dict[str, Any] = Field(default_factory=dict)
class SandboxCreateRequest(BaseModel):
profile: str
inputs: dict[str, str] = Field(default_factory=dict)
consumer: Consumer
ttl: str | None = None
class Reachability(BaseModel):
ssh: str | None = None
remote_dir: str | None = None
compose_project: str | None = None
host: str | None = None
class SandboxStatus(BaseModel):
sandbox_id: str
profile_id: str
extension_id: str
state: SandboxState
consumer: Consumer
host: str | None = None
reachability: Reachability | None = None
inputs: dict[str, str] = Field(default_factory=dict)
error: str | None = None
created_at: datetime
updated_at: datetime
ready_at: datetime | None = None
destroyed_at: datetime | None = None

View File

@@ -0,0 +1,28 @@
"""Host placement resolution from profile policy and environment."""
from __future__ import annotations
import os
from sandboxer.models import Profile
def resolve_host(profile: Profile, *, override: str | None = None) -> str:
if override:
return override
env_host = os.environ.get("SANDBOXER_HOST")
if env_host:
return env_host
for candidate in [*profile.placement.prefer, *profile.placement.fallback]:
mapped = os.environ.get(f"SANDBOXER_HOST_{candidate.upper()}")
if mapped:
return mapped
if candidate in os.environ:
return os.environ[candidate]
if profile.placement.prefer:
return profile.placement.prefer[0]
if profile.placement.fallback:
return profile.placement.fallback[0]
raise ValueError(
"No host resolved. Set SANDBOXER_HOST or profile placement hosts in environment."
)

View File

@@ -0,0 +1,35 @@
"""Load and validate profile definitions from YAML files."""
from __future__ import annotations
from pathlib import Path
import yaml
from sandboxer.models import Profile
_REPO_ROOT = Path(__file__).resolve().parents[3]
_PROFILES_DIR = _REPO_ROOT / "profiles"
def profiles_dir() -> Path:
return _PROFILES_DIR
def load_profile(profile_id: str, *, profiles_root: Path | None = None) -> Profile:
root = profiles_root or _PROFILES_DIR
path = root / f"{profile_id}.yaml"
if not path.exists():
raise FileNotFoundError(f"Profile not found: {profile_id} ({path})")
raw = yaml.safe_load(path.read_text())
profile = Profile.model_validate(raw)
if profile.id != profile_id:
raise ValueError(f"Profile id mismatch: file {profile_id}, content {profile.id}")
return profile
def list_profile_ids(*, profiles_root: Path | None = None) -> list[str]:
root = profiles_root or _PROFILES_DIR
if not root.exists():
return []
return sorted(p.stem for p in root.glob("*.yaml"))

84
tests/test_manager.py Normal file
View File

@@ -0,0 +1,84 @@
"""SandboxManager unit tests with mocked backend."""
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import patch
import pytest
from sandboxer.core.manager import SandboxManager
from sandboxer.lifecycle.store import SandboxStore
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest, SandboxState, SandboxStatus
class FakeBackend:
def provision(self, profile, inputs, host):
return {
"sandbox_id": "test1234",
"host": host,
"remote_dir": "/tmp/sandboxer/test1234",
"compose_project": "sbx-e2e-test1234",
"compose_file": "docker-compose.yml",
"ssh_user": "root",
}
def wait_ready(self, handle):
return {
"ssh": f"root@{handle['host']}",
"remote_dir": handle["remote_dir"],
"compose_project": handle["compose_project"],
"host": handle["host"],
}
def teardown(self, handle):
return {
"compose_removed": "True",
"remote_dir_removed": "True",
"remote_dir": handle["remote_dir"],
}
@pytest.fixture
def store(tmp_path: Path) -> SandboxStore:
return SandboxStore(path=tmp_path / "sandboxes.json")
def test_create_and_destroy(store: SandboxStore) -> None:
manager = SandboxManager(store=store)
request = SandboxCreateRequest(
profile="profile.compose-e2e",
inputs={"repo": "/tmp/repo"},
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
)
with (
patch("sandboxer.core.manager.resolve_backend", return_value=FakeBackend()),
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
patch("sandboxer.core.manager.resolve_host", return_value="coulombcore"),
):
status = manager.create(request)
assert status.state == SandboxState.READY
assert status.sandbox_id == "test1234"
destroyed = manager.destroy(status.sandbox_id)
assert destroyed.state == SandboxState.DESTROYED
def test_destroy_idempotent(store: SandboxStore) -> None:
now = datetime.now(UTC)
status = SandboxStatus(
sandbox_id="gone1234",
profile_id="profile.compose-e2e",
extension_id="ext.compose-ssh",
state=SandboxState.DESTROYED,
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
created_at=now,
updated_at=now,
destroyed_at=now,
)
store.save(status)
manager = SandboxManager(store=store)
result = manager.destroy("gone1234")
assert result.state == SandboxState.DESTROYED

61
tests/test_models.py Normal file
View File

@@ -0,0 +1,61 @@
"""Schema and loader tests."""
from pathlib import Path
import pytest
import yaml
from sandboxer.extensions.registry import load_all_extensions, load_extension
from sandboxer.models import Consumer, Profile, SandboxCreateRequest
from sandboxer.profiles.loader import load_profile
def test_load_compose_e2e_profile() -> None:
profile = load_profile("profile.compose-e2e")
assert profile.extension == "ext.compose-ssh"
assert profile.placement.prefer == ["sandboxer01"]
def test_load_compose_ssh_extension() -> None:
ext = load_extension("ext.compose-ssh")
assert ext.handler.endswith("ComposeSSHExtension")
assert "container" in ext.capabilities.isolation_levels
def test_load_all_extensions_no_duplicates() -> None:
extensions = load_all_extensions()
assert "ext.compose-ssh" in extensions
assert len(extensions) == len(set(extensions))
def test_profile_roundtrip(tmp_path: Path) -> None:
data = load_profile("profile.compose-e2e").model_dump()
path = tmp_path / "profile.test.yaml"
path.write_text(yaml.dump(data))
loaded = Profile.model_validate(yaml.safe_load(path.read_text()))
assert loaded.id == "profile.compose-e2e"
def test_sandbox_create_request() -> None:
req = SandboxCreateRequest(
profile="profile.compose-e2e",
inputs={"repo": "/tmp/foo"},
consumer=Consumer(actor="adm", project="sand-boxer"),
)
assert req.inputs["repo"] == "/tmp/foo"
def test_extension_missing_handler(tmp_path: Path) -> None:
bad = tmp_path / "ext.bad.yaml"
bad.write_text(
yaml.dump(
{
"id": "ext.bad",
"title": "Bad",
"handler": "",
"capabilities": {"isolation_levels": ["container"], "pricing_model": "self-hosted"},
}
)
)
with pytest.raises(ValueError, match="missing handler"):
load_extension("ext.bad", extensions_root=tmp_path)

280
uv.lock generated
View File

@@ -10,6 +10,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
]
[[package]]
name = "anyio"
version = "4.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506 },
]
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289 },
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -19,6 +50,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -67,6 +144,123 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ 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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262 },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872 },
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255 },
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827 },
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051 },
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314 },
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146 },
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685 },
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420 },
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122 },
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573 },
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139 },
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433 },
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513 },
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114 },
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298 },
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158 },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724 },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742 },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418 },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274 },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940 },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516 },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854 },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306 },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044 },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133 },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464 },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823 },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919 },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693 },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819 },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354 },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238 },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575 },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624 },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325 },
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589 },
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552 },
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984 },
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417 },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527 },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024 },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696 },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590 },
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782 },
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146 },
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492 },
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604 },
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828 },
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000 },
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286 },
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071 },
]
[[package]]
name = "pygments"
version = "2.20.0"
@@ -92,6 +286,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
]
[[package]]
name = "rich"
version = "15.0.0"
@@ -135,6 +384,9 @@ name = "sandboxer"
version = "0.0.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "pydantic" },
{ name = "pyyaml" },
{ name = "typer" },
]
@@ -145,7 +397,12 @@ dev = [
]
[package.metadata]
requires-dist = [{ name = "typer", specifier = ">=0.12" }]
requires-dist = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "pydantic", specifier = ">=2.0" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "typer", specifier = ">=0.12" },
]
[package.metadata.requires-dev]
dev = [
@@ -176,3 +433,24 @@ sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410d
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456 },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 },
]

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Meta-framework foundation and first extension"
domain: infotech
repo: sand-boxer
status: ready
status: active
owner: codex
topic_slug: custodian
created: "2026-06-22"
@@ -27,7 +27,7 @@ place; T03 package layout partially satisfied)
```task
id: SAND-WP-0002-T01
status: todo
status: done
priority: high
state_hub_task_id: "5a45289c-3130-40f2-99e0-fbb533f56bda"
```
@@ -51,7 +51,7 @@ validator, or codegen concerns.
```task
id: SAND-WP-0002-T02
status: todo
status: done
priority: high
state_hub_task_id: "5747603d-bed2-4d4b-9287-9949befff0c2"
```
@@ -73,7 +73,7 @@ metadata.
```task
id: SAND-WP-0002-T03
status: todo
status: done
priority: high
state_hub_task_id: "c90d6aaf-8dc7-4ea2-8c78-b3c9255e331e"
```
@@ -97,7 +97,7 @@ and CLI entry point. Satisfies SAND-WP-0001-T02 if not already done.
```task
id: SAND-WP-0002-T04
status: todo
status: done
priority: high
state_hub_task_id: "578105e8-947b-4755-aeff-181ddb85d750"
```
@@ -115,7 +115,7 @@ No SaaS extensions in this workplan — self-hosted only.
```task
id: SAND-WP-0002-T05
status: todo
status: done
priority: high
state_hub_task_id: "6262786d-1019-46a2-b745-b111dfe83620"
```
@@ -135,7 +135,7 @@ Provide a compatibility note in extension README for interim `make e2e` callers.
```task
id: SAND-WP-0002-T06
status: todo
status: done
priority: high
state_hub_task_id: "79b22b16-17f3-48eb-a4ad-9eae88f94202"
```
@@ -163,7 +163,7 @@ snuggle-inventor call the same functions.
```task
id: SAND-WP-0002-T07
status: todo
status: done
priority: medium
state_hub_task_id: "79312c62-1213-4045-8bf6-84030f6b9aa7"
```
@@ -182,7 +182,7 @@ identities. Document contract in meta-framework spec.
```task
id: SAND-WP-0002-T08
status: todo
status: done
priority: medium
state_hub_task_id: "27221d26-7900-46e4-8c4e-1012023afb65"
```
@@ -202,7 +202,7 @@ Cross-link from `INTENT.md` Coulomb boundaries section.
```task
id: SAND-WP-0002-T09
status: todo
status: done
priority: medium
state_hub_task_id: "e2b089b2-3742-4feb-86c3-788a1f6ffb81"
```
@@ -234,6 +234,9 @@ End-to-end proof on CoulombCore or sandboxer01 (when reachable):
Record gaps for wise-validator migration (SAND-WP-0003) and `the-custodian`
shim (SAND-WP-0004).
Runbook published at `docs/runbooks/profile-compose-e2e.md`. Remote host smoke
test (steps 14) pending operator run against CoulombCore/sandboxer01.
---
## Out of scope (follow-on workplans)