Compare commits

..

28 Commits

Author SHA1 Message Date
544010880c chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-07-02:
  - update .custodian-brief.md for artifact-store
2026-07-02 11:27:11 +02:00
1a0acf60f9 ARTIFACT-STORE-WP-0007 D7.5 + finish: follow-up routing and MinIO-compatibility decision
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:26:45 +02:00
01c88169f4 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-07-02:
  - update .custodian-brief.md for artifact-store
2026-07-02 11:24:02 +02:00
8fbce69475 ARTIFACT-STORE-WP-0007 D7.4: STS temporary credential support (session token + refreshable file refs)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:23:44 +02:00
68a5ff0ba2 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-07-02:
  - update .custodian-brief.md for artifact-store
2026-07-02 11:16:13 +02:00
f4a7b92543 ARTIFACT-STORE-WP-0007 D7.3: STS credential vending assessment for NetKingdom
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:15:45 +02:00
e1dd365586 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-07-02:
  - update .custodian-brief.md for artifact-store
2026-07-02 11:13:19 +02:00
83ce888e78 ARTIFACT-STORE-WP-0007 D7.2: deterministic local MinIO fixture, live compatibility pass
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:13:03 +02:00
8f454da406 Advance MinIO compatibility lane 2026-06-27 23:37:55 +02:00
5858a7309b chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-27:
  - update .custodian-brief.md for artifact-store
2026-06-27 23:32:57 +02:00
cdcd2390c4 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for artifact-store
2026-06-22 23:19:06 +02:00
9561e9620f Normalize agent instructions and workplan frontmatter (STATE-WP-0067)
- Align agent files with on-disk workplan prefixes (infer from workplan ids)
- Set workplan domain to registered domain_slug; add topic_slug where applicable
- Repair frontmatter delimiter formatting; migrate legacy task status literals
- Regenerate AGENTS.md, CLAUDE.md, and .claude/rules from State Hub templates
2026-06-22 23:16:24 +02:00
fca7b7c0e8 Add .repo-classification.yaml (CUST-WP-0050 T11 agent first-pass) 2026-06-22 17:47:34 +02:00
e689929da0 Add credential routing instructions for all agent runtimes
Propagate shared credential-routing section (Codex, Claude, Grok, llm-connect)
from state-hub template via scripts/propagate_credential_routing.py.
2026-06-18 22:48:37 +02:00
0fd2f29e87 Add capability registry scaffold (REUSE-WP-0014-T03 B01)
Empty helix_forge registry layout for federation publishing.
2026-06-16 01:50:12 +02:00
bcda9ad1d7 Refresh agent instruction files 2026-05-18 16:55:40 +02:00
93ca9ea3b9 Add MinIO STS follow-up workstream 2026-05-17 13:45:01 +02:00
4df29ba9f0 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for artifact-store
2026-05-17 13:37:34 +02:00
cbbbe93594 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for artifact-store
2026-05-17 00:26:54 +02:00
a60d24f814 Add reference-counted garbage collection 2026-05-17 00:26:19 +02:00
1dae855700 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for artifact-store
2026-05-17 00:24:54 +02:00
91bb08c8e5 Add guide-board pilot ingestion 2026-05-17 00:09:11 +02:00
1f379ba321 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for artifact-store
2026-05-17 00:07:30 +02:00
864f7f203c Add S3 backend and storage verification 2026-05-16 23:26:03 +02:00
b7ceaf7682 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-16:
  - update .custodian-brief.md for artifact-store
2026-05-16 23:24:37 +02:00
c33baa3635 Implement HTTP ingestion and retention lifecycle 2026-05-16 23:10:21 +02:00
2173f702c1 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-16:
  - update .custodian-brief.md for artifact-store
2026-05-16 23:07:57 +02:00
b9ae4d83ba chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-16:
  - ARTIFACT-STORE-WP-0003-T001: done → cancelled
2026-05-16 23:07:56 +02:00
67 changed files with 7140 additions and 230 deletions

20
.claude/rules/agents.md Normal file
View File

@@ -0,0 +1,20 @@
## Kaizen Agents
Specialized agent personas available on demand via the state-hub MCP.
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
Common agents:
| Agent | Category | When to use |
|-------|----------|-------------|
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
| `test-maintenance` | testing | Diagnose and fix failing tests |
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
| `keepaTodofile` | process | Maintain TODO.md during work |
| `project-management` | process | Track status, determine next steps |
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
All 17 agents: call `list_kaizen_agents()` for the full list.

View File

@@ -0,0 +1,8 @@
## Architecture
<!-- TODO: Describe the key design decisions and component structure.
Key modules, data flows, external integrations, state machines, etc. -->
## Quick Reference
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference

View File

@@ -0,0 +1,50 @@
# Credential and access routing
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
other credential need belongs to another subsystem. **Do not** message
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
### Lookup (do this first)
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=artifact-store` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
### Quick routing table
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes**`warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
every repo's agent instructions because it is high-frequency, high-risk, and easy to
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`

View File

@@ -0,0 +1,38 @@
## First Session Protocol
Triggered when `get_domain_summary("infotech")` shows **no workstreams**.
The project is registered but work has not yet been structured.
**Step 1 — Read, don't write**
- `~/the-custodian/canon/projects/infotech/project_charter_v0.1.md` — purpose, scope
- `~/the-custodian/canon/projects/infotech/roadmap_v0.1.md` — planned phases
- Scan repo root: README, directory structure, existing code or docs
**Step 2 — Survey in-progress work**
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
**Step 3 — Propose workstreams to Bernd**
Propose 13 workstreams — each a coherent strand, weeks to months, anchored to a
roadmap phase. **Wait for approval before creating.**
**Step 4 — Create workplan file first, then DB record (ADR-001)**
```
workplans/ARTIFACT-STORE-WP-NNNN-<slug>.md ← write this first
```
Then register in the hub:
```
create_workstream(topic_id="595afc64-bd28-47bf-aafb-ba230b28371b", title="...", owner="...", description="...")
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
```
**Step 5 — Record the setup**
```
add_progress_event(
summary="First session: structured infotech into N workstreams, M tasks",
event_type="milestone",
topic_id="595afc64-bd28-47bf-aafb-ba230b28371b",
detail={"workstreams": [...], "tasks_created": M}
)
```
<!-- Delete or archive this file once past first session -->

View File

@@ -0,0 +1,8 @@
## Repo boundary
This repo owns **artifact-store** only. It does not own:
<!-- TODO: List what belongs in adjacent repos, e.g.:
- SSH key management → railiance-infra/
- State hub code → state-hub/
-->

View File

@@ -0,0 +1,5 @@
**Purpose:** Generic artifact registry and storage gateway for generated outputs, evidence packages, reports, logs, snapshots, exports, and release artifacts.
**Domain:** infotech
**Repo slug:** artifact-store
**Topic ID:** 595afc64-bd28-47bf-aafb-ba230b28371b

View File

@@ -0,0 +1,85 @@
## Session Protocol
Dev Hub (State Hub API): http://127.0.0.1:8000
MCP server name in `~/.claude.json`: `dev-hub`
**Step 1 — Orient**
Read the offline-safe brief first — it works without a live hub connection:
```bash
cat .custodian-brief.md
```
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
```
get_domain_summary("infotech")
```
If MCP tools are unavailable in the current agent session, use the REST API:
```bash
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
```
If the hub is offline: `cd ~/state-hub && make api`
**Step 2 — Check inbox**
With MCP tools:
```
get_messages(to_agent="artifact-store", unread_only=True)
```
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
requests before proceeding.
Without MCP tools:
```bash
curl -s "http://127.0.0.1:8000/messages/?to_agent=artifact-store&unread_only=true" \
| python3 -m json.tool
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
**Step 3 — Scan workplans**
```bash
ls workplans/
```
For each file with `status: ready`, `active`, or `blocked`, note pending
`wait`/`todo`/`progress` tasks.
**Step 4 — Present brief**
1. **Active workstreams** for `infotech` — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:artifact-store]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
- `alignment_warnings`: flag if active work is not aligned with current goal
4. **Suggested next action** — highest-priority open item
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
If no workstreams: follow First Session Protocol (`first-session.md`).
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
**Session close:**
With MCP tools:
```
add_progress_event(summary="...", topic_id="595afc64-bd28-47bf-aafb-ba230b28371b", workstream_id="<uuid>")
```
Without MCP tools:
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{"topic_id":"595afc64-bd28-47bf-aafb-ba230b28371b","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
```
If workplan files were modified, ensure the local copy is up to date first:
```bash
git -C <repo_path> pull --ff-only
cd ~/state-hub && make fix-consistency REPO=artifact-store
```
For repos where implementation runs on a remote machine (e.g. CoulombCore),
use the combined target which pulls before fixing:
```bash
cd ~/state-hub && make fix-consistency-remote REPO=artifact-store
```
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
until you pull — intentional to prevent clobbering remote progress.

View File

@@ -0,0 +1,19 @@
## Stack
<!-- TODO: Fill in language, frameworks, and key dependencies -->
- **Language:**
- **Key deps:**
## Dev Commands
```bash
# TODO: Fill in the standard commands for this repo
# Install dependencies
# Run tests
# Lint / type check
# Build / package (if applicable)
```

View File

@@ -0,0 +1,40 @@
## Workplan Convention (ADR-001)
File location: `workplans/ARTIFACT-STORE-WP-NNNN-<slug>.md`
ID prefix: `ARTIFACT-STORE-WP-`
Work items originate as files in this repo **before** being registered in the hub.
Canonical workplan/workstream frontmatter statuses are:
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
Use `proposed` for a newly drafted plan, `ready` after review against current
repo state, and `finished` when implementation is complete. `stalled` and
`needs_review` are derived health labels, not stored statuses.
Closed workplans may be moved to `workplans/archived/` with a completion-date
prefix: `YYMMDD-ARTIFACT-STORE-WP-NNNN-<slug>.md`. The frontmatter id remains
unchanged; the prefix is only for quick visual reference.
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
directly. Promote anything requiring analysis, design, approval, dependencies, or
multiple planned phases into a normal workplan.
Ecosystem todos from other agents arrive as `[repo:artifact-store]` hub tasks —
visible at session start. Pick one up by creating the workplan file, then registering
the workstream.
Task blocks use this shape:
```task
id: ARTIFACT-STORE-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
```
Status progression is `todo``progress``done`; use `wait` for waiting or
blocked work and `cancel` for stopped work.
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->

View File

@@ -1,46 +1,18 @@
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
# Custodian Brief — artifact-store
**Domain:** stack
**Last synced:** 2026-05-16 20:41 UTC
**Domain:** infotech
**Last synced:** 2026-07-02 09:27 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams
### Retention Lifecycle: Defaults, Extensions, Holds, Deletion Eligibility
Progress: 0/5 done | workstream_id: `84930f4c-3bcf-415e-a94c-bfa854a15871`
**Open tasks:**
- ► D3.2 - Retention Extensions `66576e53`
- ► D3.3 - Holds (Apply And Release) `8164e448`
- ► D3.4 - Deletion Eligibility Sweeper `fe13cd0d`
- ► D3.5 - Audit Surface For Retention `7dce0c92`
- · D3.1 - Default Retention Application `25531837`
### Guide-Board Pilot Ingestion
Progress: 0/5 done | workstream_id: `701c4d8c-5cf4-4a4a-ab60-1dcae53fe771`
**Open tasks:**
- · D5.1 - Pilot Metadata Schema Registration `830f6822`
- · D5.2 - Pilot Ingest Helper (CLI + Library Function) `ff0ba2eb`
- · D5.3 - Fixture-Based Test `5c367257`
- · D5.4 - Statehub Linkage Recipe `b1ca7133`
- · D5.5 - Operator Smoke Procedure For The Real Producer `bffa3573`
### S3-Compatible Backend (Ceph RGW Target)
Progress: 0/5 done | workstream_id: `d0526cfc-e532-431f-970d-f3e548d27a80`
**Open tasks:**
- · D4.1 - Configuration Surface `1db0d548`
- · D4.2 - S3 Backend Implementation `14b50595`
- · D4.3 - Backend Selection And Routing `725dafd6`
- · D4.4 - Test Strategy: MinIO In CI, RGW As Documented Manual Smoke `4fd7b73b`
- · D4.5 - Verification Pass `5a55546f`
*(none — repo may need first-session setup)*
---
## MCP Orientation (when available)
If the state-hub MCP server is reachable, call:
`get_domain_summary("stack")`
`get_domain_summary("infotech")`
This provides richer cross-domain context.
If the MCP call fails, use this file as your orientation source.

View File

@@ -14,3 +14,50 @@ ARTIFACTSTORE_STORAGE_LOCAL_ROOT=./var/storage
# Python logging level: DEBUG | INFO | WARNING | ERROR
ARTIFACTSTORE_LOG_LEVEL=INFO
# Shared-secret bearer tokens for the HTTP API. Comma- or newline-separated.
# Protected endpoints return 401 until at least one token is configured.
ARTIFACTSTORE_AUTH_TOKENS=dev-token
# Read endpoints are authenticated by default. Set true only for local demos.
ARTIFACTSTORE_ANON_READ=false
# Defaults used by `artifactstore push` and `artifactstore manifest`.
ARTIFACTSTORE_API_URL=http://127.0.0.1:8000
ARTIFACTSTORE_API_TOKEN=dev-token
# Guide-board pilot helper defaults.
ARTIFACTSTORE_GUIDE_BOARD_SCHEMA=schemas/guide-board.run.v1.json
STATE_HUB_URL=http://127.0.0.1:8000
STATE_HUB_AUTHOR=artifact-store
STATE_HUB_WORKSTREAM_ID=
STATE_HUB_TASK_ID=
GUIDE_BOARD_RUN_DIR=
ARTIFACTSTORE_INGEST_RESULT_PATH=
# Optional TOML file overriding retention class default durations.
ARTIFACTSTORE_RETENTION_CONFIG_PATH=
# Default interval for external schedulers that run `artifactstore retention sweep`.
ARTIFACTSTORE_RETENTION_SWEEP_INTERVAL_SECONDS=3600
# Storage backend selection. `local` is always available; `s3` requires the
# optional S3 dependency and the S3 settings below.
ARTIFACTSTORE_STORAGE_BACKENDS=local
ARTIFACTSTORE_STORAGE_DEFAULT_BACKEND=local
# Format: producer:retention_class=backend_id, with * wildcards.
# Example: guide-board:release-evidence=s3,*:*=local
ARTIFACTSTORE_STORAGE_BACKEND_ROUTES=
# S3-compatible backend settings for Ceph RGW / MinIO / AWS S3.
ARTIFACTSTORE_S3_ENDPOINT_URL=
ARTIFACTSTORE_S3_REGION=us-east-1
ARTIFACTSTORE_S3_BUCKET=
ARTIFACTSTORE_S3_KEY_PREFIX=artifact-store
# Secret refs must be env:NAME or file:/mounted/path.
ARTIFACTSTORE_S3_ACCESS_KEY_REF=
ARTIFACTSTORE_S3_SECRET_KEY_REF=
ARTIFACTSTORE_S3_STORAGE_CLASS=
ARTIFACTSTORE_S3_SSE=
ARTIFACTSTORE_S3_MULTIPART_THRESHOLD_BYTES=67108864
ARTIFACTSTORE_S3_MULTIPART_CHUNK_BYTES=8388608

16
.repo-classification.yaml Normal file
View File

@@ -0,0 +1,16 @@
repo_classification:
standard: Repo Classification Standard
version: '1.0'
classified_at: '2026-06-22'
classified_by: agent
category: project
domain: infotech
secondary_domains: []
capability_tags: []
business_stake:
- technology
- product
- operations
business_mechanics:
- coordination
- operation

134
AGENTS.md
View File

@@ -2,10 +2,9 @@
## Repo Identity
**Purpose:** Generic artifact registry and storage gateway for generated outputs,
evidence packages, reports, logs, snapshots, exports, and release artifacts.
**Purpose:** Generic artifact registry and storage gateway for generated outputs, evidence packages, reports, logs, snapshots, exports, and release artifacts.
**Domain:** stack
**Domain:** infotech
**Repo slug:** artifact-store
**Topic ID:** `595afc64-bd28-47bf-aafb-ba230b28371b`
**Workplan prefix:** `ARTIFACT-STORE-WP-`
@@ -64,8 +63,8 @@ Omit `workstream_id` / `task_id` when not applicable.
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"status": "in_progress"}'
# values: todo | in_progress | done | blocked
-d '{"status": "progress"}'
# values: wait | todo | progress | done | cancel
```
### Flag a task for human review
@@ -83,8 +82,8 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
**Start:**
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent=artifact-store&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: active` files and open tasks
4. Check blocked tasks: `GET /tasks/?needs_human=true`
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
**During work:**
- Update task statuses in workplan files as tasks progress
@@ -94,7 +93,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
1. Update workplan file task statuses to reflect progress
2. Log: `POST /progress/` with a summary of what changed
3. Note for the custodian operator: after workplan file changes, run from
`~/the-custodian/state-hub`:
`~/state-hub`:
```bash
make fix-consistency REPO=artifact-store
```
@@ -102,15 +101,72 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
---
## Credential and access routing
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
other credential need belongs to another subsystem. **Do not** message
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
### Lookup (do this first)
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=artifact-store` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
### Quick routing table
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
every repo's agent instructions because it is high-frequency, high-risk, and easy to
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
<!-- REPO-AGENTS-EXTENSIONS -->
<!-- Append repo-specific agent instructions below this marker.
The state-hub template sync preserves content after this line. -->
---
## Workplan Convention (ADR-001)
Work items originate as files in this repo — not in the hub. The hub is a
read/cache/index layer that rebuilds from files.
**File location:** `workplans/ARTIFACT-STORE-WP-NNNN-<slug>.md`
**File location:** `workplans/ARTIFACT-WP-NNNN-<slug>.md`
**Archived location:** completed workplans may move to
`workplans/archived/YYMMDD-ARTIFACT-STORE-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
**Archived location:** finished workplans may move to
`workplans/archived/YYMMDD-ARTIFACT-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
the completion/archive date; the frontmatter `id` does not change.
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
@@ -122,12 +178,12 @@ anything needing analysis, design, approval, dependencies, or multiple phases.
```yaml
---
id: ARTIFACT-STORE-WP-NNNN
id: ARTIFACT-WP-NNNN
type: workplan
title: "..."
domain: stack
domain: infotech
repo: artifact-store
status: active | done
status: proposed | ready | active | blocked | backlog | finished | archived
owner: codex
topic_slug: ...
created: "YYYY-MM-DD"
@@ -136,14 +192,18 @@ state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
---
```
Use `proposed` for a new draft, `ready` after review against current repo
state, and `finished` after implementation. `stalled` and `needs_review` are
derived health labels, not frontmatter statuses.
**Task block format** (one per `##` section):
```
## Task Title
` ` `task
id: ARTIFACT-STORE-WP-NNNN-T01
status: todo | in_progress | done | blocked
id: ARTIFACT-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
` ` `
@@ -151,49 +211,9 @@ state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
Task description text.
```
Status progression: `todo` → `in_progress` → `done` (or `blocked`)
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
To create a new workplan:
1. Write the file following the format above
2. Notify the custodian operator to run `make fix-consistency REPO=artifact-store`
(or send a message to the hub agent via `POST /messages/`)
---
## Current Repo Shape
v0.1 baseline (WP-0001) is live: library, CLI, minimal HTTP app, local FS
backend, end-to-end ingest + finalize + replay. The pinned tech stack is in
[ADR-0005](docs/adr/0005-v1-tech-stack.md).
Sources of truth:
- `INTENT.md` — purpose, product thesis, scope, service boundary.
- `SCOPE.md` — lightweight orientation.
- `docs/OPERATOR.md` — runbook: env vars, DB backends, CLI / HTTP reference, smoke test, replay procedure.
- `docs/ARCHITECTURE-BLUEPRINT.md` — architecture v2: modules, data model, API shape.
- `docs/PLATFORM-AMBITION.md` — longer-horizon thesis and the v1 schema commitments (A1A9).
- `docs/adr/` — architecture decision records ADR-0001 … ADR-0006 (content-addressed storage, event log as source of truth, canonical CBOR manifests, control/data plane contract, v1 tech stack, OCI reachability).
- `docs/ROADMAP.md` — workplan sequencing across phases.
- `docs/ASSEMBLY-EXPERIMENT.md` — opt-in research line on hand-tuned asm for hot kernels.
- `workplans/ARTIFACT-STORE-WP-0001-service-baseline.md` — Foundation workplan (done).
- `workplans/ARTIFACT-STORE-WP-{0002..0005}-*.md` — planned next workplans.
Local commands:
```sh
make install # uv sync --all-extras
make migrate-fresh # drop + re-create the dev SQLite DB
make dev # uvicorn on 127.0.0.1:8000
make test # pytest
make lint type # ruff + mypy --strict
artifactstore health
```
## Repo Boundary
This repo owns artifact identity, package/file metadata, storage backend
abstraction, retention policy, retrieval metadata, and audit trails.
It does not own StateHub work records, guide-board assessment semantics, formal
records-management certification, or producer-specific business logic.

12
CLAUDE.md Normal file
View File

@@ -0,0 +1,12 @@
# artifact-store — Claude Code Instructions
@SCOPE.md
@.claude/rules/repo-identity.md
@.claude/rules/session-protocol.md
@.claude/rules/first-session.md
@.claude/rules/workplan-convention.md
@.claude/rules/stack-and-commands.md
@.claude/rules/architecture.md
@.claude/rules/repo-boundary.md
@.claude/rules/credential-routing.md
@.claude/rules/agents.md

View File

@@ -1,10 +1,12 @@
.PHONY: help install dev test lint format type migrate migrate-fresh clean
.PHONY: help install dev test test-minio test-minio-local lint format type migrate migrate-fresh clean
help:
@echo "artifact-store — make targets"
@echo " install install / sync dependencies via uv"
@echo " dev run the FastAPI app with reload (uvicorn)"
@echo " test run the pytest suite"
@echo " test-minio run opt-in live MinIO compatibility tests"
@echo " test-minio-local run the same tests against a throwaway local MinIO container"
@echo " lint ruff check + ruff format --check"
@echo " format ruff format (write changes)"
@echo " type mypy --strict over src and tests"
@@ -21,6 +23,12 @@ dev:
test:
uv run pytest
test-minio:
uv run --all-extras pytest tests/integration/test_storage_s3_minio.py -m integration
test-minio-local:
bash scripts/minio_local_smoke.sh
lint:
uv run ruff check .
uv run ruff format --check .

View File

@@ -1,12 +1,11 @@
# Operator Guide
Status: v0.1 (WP-0001 baseline)
Status: v0.1 (WP-0003 baseline)
Updated: 2026-05-16
This guide is the user manual for running `artifact-store` v0.1 the
library + CLI + minimal HTTP app that landed in WP-0001. Ingest, finalize,
and retrieve workflows go through the Python library today; the HTTP
upload API arrives in WP-0002.
This guide is the user manual for running `artifact-store` v0.1: the library,
CLI, HTTP ingestion API, manifest surface, retention lifecycle, storage checks,
and the guide-board pilot path.
For architectural background see
[ARCHITECTURE-BLUEPRINT.md](ARCHITECTURE-BLUEPRINT.md), the ADRs under
@@ -50,9 +49,72 @@ All settings are prefixed with ``ARTIFACTSTORE_`` and read by
| `ARTIFACTSTORE_DATABASE_URL` | `sqlite+aiosqlite:///./var/artifactstore.db` | SQLAlchemy async URL. Alembic translates `+aiosqlite` and `+asyncpg` to their sync drivers at migrate-time. |
| `ARTIFACTSTORE_STORAGE_LOCAL_ROOT`| `./var/storage` | Root directory for the local filesystem storage backend. Created on first use. |
| `ARTIFACTSTORE_LOG_LEVEL` | `INFO` | Python logging level (`DEBUG` / `INFO` / `WARNING` / `ERROR`). |
| `ARTIFACTSTORE_AUTH_TOKENS` | empty | Comma- or newline-separated shared-secret bearer tokens for the HTTP API. |
| `ARTIFACTSTORE_ANON_READ` | `false` | Set `true` only for local demos where read endpoints may be anonymous. |
| `ARTIFACTSTORE_API_URL` | `http://127.0.0.1:8000` | Default API base URL used by HTTP-backed CLI commands. |
| `ARTIFACTSTORE_API_TOKEN` | empty | Default bearer token used by HTTP-backed CLI commands. |
| `ARTIFACTSTORE_GUIDE_BOARD_SCHEMA` | `schemas/guide-board.run.v1.json` | Schema path used by guide-board pilot bootstrap helpers. |
| `ARTIFACTSTORE_RETENTION_CONFIG_PATH` | empty | Optional TOML file overriding retention-class default durations. |
| `ARTIFACTSTORE_RETENTION_SWEEP_INTERVAL_SECONDS` | `3600` | Default interval for external schedulers that invoke the retention sweeper. |
| `ARTIFACTSTORE_STORAGE_BACKENDS` | `local` | Comma-separated backend IDs to configure (`local`, `s3`). |
| `ARTIFACTSTORE_STORAGE_DEFAULT_BACKEND` | `local` | Backend used when no routing rule matches. |
| `ARTIFACTSTORE_STORAGE_BACKEND_ROUTES` | empty | Comma-separated `producer:retention_class=backend_id` rules; `*` is a wildcard. |
| `ARTIFACTSTORE_S3_ENDPOINT_URL` | empty | S3-compatible endpoint URL for Ceph RGW / MinIO / AWS S3. |
| `ARTIFACTSTORE_S3_REGION` | `us-east-1` | S3 signing region. |
| `ARTIFACTSTORE_S3_BUCKET` | empty | Bucket/container for artifact objects. |
| `ARTIFACTSTORE_S3_KEY_PREFIX` | empty | Optional object-key prefix before `<algorithm>/<hex...>`. |
| `ARTIFACTSTORE_S3_ACCESS_KEY_REF` | empty | Access key reference, `env:NAME` or `file:/mounted/path`. |
| `ARTIFACTSTORE_S3_SECRET_KEY_REF` | empty | Secret key reference, `env:NAME` or `file:/mounted/path`. |
| `ARTIFACTSTORE_S3_SESSION_TOKEN_REF` | empty | Optional STS session token reference for temporary credentials, `env:NAME` or `file:/mounted/path`. When any credential ref is `file:`-based, all refs are re-resolved per client, so a sidecar/controller can rotate the three values atomically without a restart. |
| `ARTIFACTSTORE_S3_STORAGE_CLASS` | empty | Optional storage class sent on writes. |
| `ARTIFACTSTORE_S3_SSE` | empty | Optional server-side encryption value, e.g. `AES256`. |
| `ARTIFACTSTORE_S3_MULTIPART_THRESHOLD_BYTES` | `67108864` | Multipart threshold for the S3 backend. |
| `ARTIFACTSTORE_S3_MULTIPART_CHUNK_BYTES` | `8388608` | Multipart part size for the S3 backend. |
| `STATE_HUB_URL` | `http://127.0.0.1:8000` | State Hub base URL used by guide-board linkage helpers. |
| `STATE_HUB_WORKSTREAM_ID` | empty | Optional workstream id for State Hub linkage events. |
| `STATE_HUB_TASK_ID` | empty | Optional task id for State Hub linkage events. |
See [`.env.example`](../.env.example) for the canonical template.
### Retention policy TOML
By default, retention durations come from the seeded `retention_classes`
rows. Operators can override the default duration per class with
`ARTIFACTSTORE_RETENTION_CONFIG_PATH`:
```toml
[retention_classes.transient]
default_duration_seconds = 86400
[retention_classes."raw-evidence"]
default_duration_seconds = 7776000
[retention_classes."summary-evidence"]
default_duration_seconds = 31536000
[retention_classes."release-evidence"]
default_duration_seconds = 220752000
[retention_classes."permanent-record"]
# Omit default_duration_seconds for no expiry.
```
Run `artifactstore retention sweep` from cron or another scheduler to mark
expired, unheld packages eligible for deletion. Then run
`artifactstore retention gc` to release the eligible packages' storage
locations and delete physical objects whose final reference has been
released:
```sh
artifactstore retention sweep
artifactstore retention gc
```
GC is reference-counted by `(backend_id, content_address)`: shared bytes stay in
the backend until every non-deleted storage location has been released. Each
released location emits a `v1.storage.location_deleted` event. A package becomes
`garbage_collected` only after all of its storage locations are released.
## Database backends
### SQLite (development default)
@@ -100,8 +162,84 @@ Objects are addressed by content (`blake3:<hex>`) and laid out as
<root>/<algorithm>/<hex[0:2]>/<hex[2:4]>/<hex>
```
with atomic writes (tmpfile + fsync + rename). The S3-compatible backend
lands in WP-0004.
with atomic writes (tmpfile + fsync + rename).
### S3-compatible backend
The `s3` backend targets Ceph RGW first, with MinIO as the development
stand-in and AWS S3 as an interoperability check. Install the optional S3
dependency before enabling it:
```sh
uv sync --all-extras --extra s3
```
Ceph RGW example:
```sh
export ARTIFACTSTORE_STORAGE_BACKENDS=local,s3
export ARTIFACTSTORE_STORAGE_DEFAULT_BACKEND=s3
export ARTIFACTSTORE_STORAGE_BACKEND_ROUTES='guide-board:release-evidence=s3,*:*=local'
export ARTIFACTSTORE_S3_ENDPOINT_URL=https://rgw.example.internal
export ARTIFACTSTORE_S3_REGION=us-east-1
export ARTIFACTSTORE_S3_BUCKET=artifact-store
export ARTIFACTSTORE_S3_KEY_PREFIX=prod/artifact-store
export ARTIFACTSTORE_S3_ACCESS_KEY_REF=env:ARTIFACTSTORE_RGW_ACCESS_KEY
export ARTIFACTSTORE_S3_SECRET_KEY_REF=file:/run/secrets/artifactstore-rgw-secret
export ARTIFACTSTORE_S3_STORAGE_CLASS=STANDARD
export ARTIFACTSTORE_S3_SSE=AES256
```
Manual smoke against Ceph RGW:
```sh
artifactstore health
artifactstore push ./fixtures/smoke \
--producer guide-board \
--subject rgw-smoke \
--retention-class release-evidence
artifactstore storage verify --backend s3
```
Opt-in MinIO compatibility smoke:
```sh
export ARTIFACTSTORE_MINIO_ENDPOINT_URL=http://127.0.0.1:9000
export ARTIFACTSTORE_MINIO_REGION=us-east-1
export ARTIFACTSTORE_MINIO_BUCKET=artifact-store-compat
export ARTIFACTSTORE_MINIO_KEY_PREFIX=compat/manual
export ARTIFACTSTORE_MINIO_ACCESS_KEY=<from-approved-local-env>
export ARTIFACTSTORE_MINIO_SECRET_KEY=<from-approved-local-env>
make test-minio
```
The MinIO bucket must already exist and the access key must have `s3:PutObject`,
`s3:GetObject`, `s3:DeleteObject`, `s3:ListBucket`, and multipart permissions on
the test prefix. Do not commit or paste the key values; use the approved local
environment, file, or operator custody path. The live test skips unless all
required `ARTIFACTSTORE_MINIO_*` values are present.
The same endpoint can be used for an application-level S3 smoke by mapping the
approved local values through the regular secret-reference settings:
```sh
export ARTIFACTSTORE_STORAGE_BACKENDS=local,s3
export ARTIFACTSTORE_STORAGE_DEFAULT_BACKEND=s3
export ARTIFACTSTORE_S3_ENDPOINT_URL="$ARTIFACTSTORE_MINIO_ENDPOINT_URL"
export ARTIFACTSTORE_S3_REGION="$ARTIFACTSTORE_MINIO_REGION"
export ARTIFACTSTORE_S3_BUCKET="$ARTIFACTSTORE_MINIO_BUCKET"
export ARTIFACTSTORE_S3_KEY_PREFIX="$ARTIFACTSTORE_MINIO_KEY_PREFIX"
export ARTIFACTSTORE_S3_ACCESS_KEY_REF=env:ARTIFACTSTORE_MINIO_ACCESS_KEY
export ARTIFACTSTORE_S3_SECRET_KEY_REF=env:ARTIFACTSTORE_MINIO_SECRET_KEY
artifactstore health
artifactstore storage verify --backend s3
```
The verification command re-reads stored objects, recomputes the primary
digest, emits `v1.storage.location_verified`, and marks failed locations as
`failed`. A nonzero failed-location count degrades `/health`.
## CLI reference
@@ -113,21 +251,31 @@ lands in WP-0004.
| `artifactstore migrate` | Run `alembic upgrade head` against the configured database. |
| `artifactstore replay` | Truncate every materialised view and rebuild it from the event log; prints the highest sequence applied. |
| `artifactstore health` | JSON liveness summary (db, backend, status). Same payload as the HTTP `/health` endpoint. |
| `artifactstore push <dir>` | Push a directory through the HTTP API and finalize the package. |
| `artifactstore manifest <package_id>` | Fetch the JSON manifest projection through the HTTP API. |
| `artifactstore retention sweep` | Run one deletion-eligibility sweep against the configured DB. |
| `artifactstore retention gc` | Run one reference-counted garbage-collection pass. |
| `artifactstore storage verify --backend <id>` | Re-read stored objects for a backend and record verification events. |
| `artifactstore guide-board ingest <run-dir>` | Ingest one guide-board run directory as an artifact package. |
The CLI is a thin client over `artifactstore.registry.Registry`
(see [ADR-0005](adr/0005-v1-tech-stack.md)).
## HTTP reference (v0.1)
| Route | Purpose |
|----------------|---------|
| `GET /` | Service banner (scaffold marker). |
| `GET /health` | Liveness summary. Returns ``{status, db, backend, version}``. `status` is `ok` only when both the DB probe (`SELECT 1`) and the backend `health()` succeed. |
| `GET /docs` | FastAPI's interactive OpenAPI docs (`/openapi.json` underneath). |
| Route family | Purpose |
|-----------------------|---------|
| `GET /`, `GET /health` | Anonymous service banner and liveness summary. |
| `GET /docs`, `GET /openapi.json` | FastAPI's interactive OpenAPI docs and generated schema. |
| `/packages...` | Create, list, inspect, upload files to, finalize, and retrieve manifests for packages. |
| `/files...` | File metadata and byte downloads, including single-range reads. |
| `/uploads...` | Upload-session wire shape for whole-body v1 uploads. |
| `/packages/{id}/retention...` | Extend retention, apply/release holds, and read retention history. |
| `POST /metadata-schemas` | Register package metadata schemas by slug. |
| `GET /events` | Long-poll event feed, CBOR by default or JSON with `Accept: application/json`. |
Package CRUD, file upload/download, manifest retrieval, retention controls,
and the event stream all land in WP-0002WP-0003. Today they are reachable
via the Python library.
All non-health routes require a bearer token unless
`ARTIFACTSTORE_ANON_READ=true` is set for read endpoints.
## End-to-end smoke test (Python library)
@@ -176,6 +324,14 @@ asyncio.run(main())
Prerequisites: `make migrate-fresh` has been run so the schema and the
retention class seeds exist.
## Guide-board pilot
The guide-board pilot stores a run directory as one artifact package and records
only package identifiers in State Hub. See
[docs/pilots/guide-board.md](pilots/guide-board.md) for schema registration,
the real `~/guide-board` plus `~/open-cmis-tck` smoke procedure, and the exact
`POST /progress/` linkage payload.
## Replay / disaster recovery
Every state-changing operation writes one row to `events` and updates the
@@ -212,6 +368,8 @@ sequence order through the canonical view writer. The result is
and the v1 schema commitments.
- [ROADMAP.md](ROADMAP.md) — workplan sequencing.
- [ASSEMBLY-EXPERIMENT.md](ASSEMBLY-EXPERIMENT.md) — opt-in asm research line.
- [pilots/guide-board.md](pilots/guide-board.md) — guide-board pilot ingestion
and State Hub linkage.
### Architecture Decision Records

View File

@@ -1,7 +1,7 @@
# Roadmap
Status: living document
Updated: 2026-05-15
Updated: 2026-05-17
The roadmap sequences `artifact-store` from "no code" to a credible
production v1 to the longer-horizon platform shape recorded in
@@ -43,11 +43,12 @@ S3-compatible store.
| ID | Title | Notes |
|---|---|---|
| WP-0006 | Garbage collection + reference counting | Required by ADR-0001 global dedup. Mark-eligible already lands in WP-0003; this workplan does the byte-deletion pass. |
| WP-0007 | Resumable / chunked upload implementation | The wire shape lands in WP-0002; this workplan makes the implementation actually streaming. |
| WP-0008 | Auth, multi-tenancy, quota | OIDC integration; tenant namespacing; per-tenant rate limit and storage quota. |
| WP-0009 | Observability: metrics, tracing, structured logs | OpenTelemetry SDK; latency / throughput SLOs published. |
| WP-0010 | Event stream out (CDC) | NATS or Kafka topic of registry events; long-poll `/events` becomes a fallback. |
| WP-0011 | Signed manifests | Sigstore / cosign integration; signature recorded alongside manifest digest. |
| WP-0007 | MinIO compatibility, MaxIO fork assessment, STS credential vending | Splits MinIO/community-fork and NetKingdom credential-vending work out of WP-0004/WP-0005 blockers. |
| WP-0008 | Resumable / chunked upload implementation | The wire shape lands in WP-0002; this workplan makes the implementation actually streaming. |
| WP-0009 | Auth, multi-tenancy, quota | OIDC integration; tenant namespacing; per-tenant rate limit and storage quota. |
| WP-0010 | Observability: metrics, tracing, structured logs | OpenTelemetry SDK; latency / throughput SLOs published. |
| WP-0011 | Event stream out (CDC) | NATS or Kafka topic of registry events; long-poll `/events` becomes a fallback. |
| WP-0012 | Signed manifests | Sigstore / cosign integration; signature recorded alongside manifest digest. |
Exit criteria for v0.3: a deployment is operatable by humans without
internal knowledge; SLOs are measurable; access is authenticated;
@@ -57,12 +58,12 @@ artifacts can be signed and verified.
| ID | Title | Notes |
|---|---|---|
| WP-0012 | OCI artifact `/v2/` endpoint | Implements OCI Distribution Spec on top of the same storage (ADR-0006). |
| WP-0013 | Content-defined chunking + global dedup at chunk level | FastCDC; chunked storage. Builds toward `docs/ASSEMBLY-EXPERIMENT.md`. |
| WP-0014 | Rust data plane extraction | Move `dataplane.inproc` to `dataplane.remote` (ADR-0004). |
| WP-0015 | WASM plugin host | Extension surface for indexers, redactors, scorecard generators. |
| WP-0016 | Cold-tier adapters | Glacier / Tape / IA classes; restore flow. |
| WP-0017 | Federation and replication | Signed manifest exchange between artifact-store instances. |
| WP-0013 | OCI artifact `/v2/` endpoint | Implements OCI Distribution Spec on top of the same storage (ADR-0006). |
| WP-0014 | Content-defined chunking + global dedup at chunk level | FastCDC; chunked storage. Builds toward `docs/ASSEMBLY-EXPERIMENT.md`. |
| WP-0015 | Rust data plane extraction | Move `dataplane.inproc` to `dataplane.remote` (ADR-0004). |
| WP-0016 | WASM plugin host | Extension surface for indexers, redactors, scorecard generators. |
| WP-0017 | Cold-tier adapters | Glacier / Tape / IA classes; restore flow. |
| WP-0018 | Federation and replication | Signed manifest exchange between artifact-store instances. |
Exit criteria for v1.0: artifact-store is embeddable as a library, runs
as a single-binary CLI, runs as a server, speaks OCI, federates between

View File

@@ -0,0 +1,89 @@
# MinIO Compatibility Landscape - 2026-06-27
## Purpose
This note closes `ARTIFACT-STORE-WP-0007-T001` by recording the dated
object-store landscape that should guide artifact-store's S3-compatible backend
and any future MaxIO decision.
## Decision
Do not start a direct MaxIO server fork from artifact-store. Treat the near-term
work as a compatibility-profile lane:
- Keep Ceph RGW as the preferred Railiance production target because it has a
mature S3 compatibility matrix, multipart support, STS-related docs, and fits
existing cluster storage ownership.
- Keep upstream MinIO only as an opt-in compatibility target for development and
migration testing, pinned by source tag or an operator-provided image whose
provenance is documented.
- Treat OpenMaxIO as a console/UI signal, not a full object-store fork candidate
for artifact-store yet.
- Keep RustFS, Garage, and SeaweedFS as evaluation targets for later harness runs
if Ceph RGW or MinIO compatibility uncovers a gap.
- Do not adopt any "Pigsty MinIO fork" until a primary source repository,
license, release process, and security update path are verified.
## Source Status
- `minio/minio` was archived on GitHub on 2026-04-25 and is read-only. The tags
page shows `RELEASE.2025-10-15T17-29-55Z` as the newest visible tag on
2026-06-27, with a commit note pointing documentation toward source-only
releases.
- MinIO AIStor documentation still documents STS. It states STS can generate
temporary credentials and convert external identity-provider credentials into
AWS Signature V4-compatible credentials.
- `AssumeRoleWithWebIdentity` remains the relevant OIDC shape for future
short-lived object-store credentials, returning access key, secret key,
expiration, and session token fields.
## Candidate Comparison
| Candidate | Fit | Risks / notes | Current call |
| --- | --- | --- | --- |
| Upstream MinIO source tag | Strong S3 behavior baseline; familiar endpoint for development smoke tests. | Archived/read-only repository, AGPLv3 obligations, source-only release posture, and unclear long-term community patch path. | Use only as a pinned compatibility target, not the production platform default. |
| OpenMaxIO UI pieces | Active public UI fork signal; useful for console affordance research. | Repository is the object browser/console, not a full server fork. It is AGPLv3 and claims broader goals than the repo currently proves. | Do not treat as the object-store backend. Track only as UI/reference material. |
| Pigsty MinIO fork | Mentioned in the workplan as a candidate to compare. | No primary source repository was verified in this assessment pass. | Not pursued until provenance is clear. |
| Garage | Small, self-hostable object store with documented S3 API workflow, Docker quick start, single-node mode, and binary/source install paths. | Not a drop-in S3 clone; compatibility and operations profile differ from MinIO/RGW. | Good later compatibility target for lightweight deployments, not first production target. |
| RustFS | Apache-2.0, S3-compatible object-store project with MinIO/Ceph migration positioning, OIDC support notes, and Docker quick start. | Project maturity and feature status need live evaluation; some distributed/KMS features are marked under testing. | Worth an opt-in harness target after MinIO/RGW path is stable. |
| SeaweedFS S3 gateway | Mature file/object system with explicit S3 gateway, documented supported bucket/object/multipart APIs, and STS/OIDC/IAM API references. | Different storage model and bucket/collection semantics; may be more platform than artifact-store needs. | Evaluate only if large-scale filer/backup needs make it attractive. |
| Ceph RGW | Existing production-aligned target. Ceph documents S3 API compatibility, supported core bucket/object operations, multipart uploads, storage classes, STS, and Keycloak/OIDC references. | Needs cluster/operator storage ownership and explicit credential custody. | Preferred production target for Railiance. |
## Harness Implications
The compatibility harness should stay backend-agnostic and run against an
operator-provided S3 endpoint. For MinIO today, the repo now provides an opt-in
pytest module that:
- skips unless explicit `ARTIFACTSTORE_MINIO_*` environment variables are set;
- performs a small put/get/range/head/delete round trip;
- performs a multipart upload with a 5 MiB part size so MinIO-compatible servers
see realistic multipart behavior;
- avoids storing any secret values in Git, State Hub, test output, or docs.
The same shape can be reused later for Ceph RGW, RustFS, Garage, or SeaweedFS by
renaming the environment variables or parameterizing the fixture.
## STS Follow-Up
STS credential vending remains `ARTIFACT-STORE-WP-0007-T003`/`T004` work. The
source assessment confirms the shape to test, but implementation ownership still
belongs to identity/platform routing:
- issuer: KeyCape/local-identity or another approved OIDC issuer;
- custody: OpenBao/operator path for any long-lived bootstrap secret;
- consumer: artifact-store S3 backend can consume temporary access key, secret
key, and session token once the config supports session tokens or an external
refresher injects standard SDK credentials;
- audit: State Hub records only non-secret request metadata and evidence ids.
## Sources Checked
- https://github.com/minio/minio/tags
- https://docs.min.io/aistor/developers/security-token-service/
- https://docs.min.io/aistor/developers/security-token-service/assumerolewithwebidentity/
- https://github.com/OpenMaxIO/openmaxio-object-browser
- https://garagehq.deuxfleurs.fr/documentation/quick-start/
- https://github.com/rustfs/rustfs
- https://github.com/seaweedfs/seaweedfs/wiki/Amazon-S3-API
- https://docs.ceph.com/en/latest/radosgw/s3/

162
docs/pilots/guide-board.md Normal file
View File

@@ -0,0 +1,162 @@
# Guide-Board Pilot
Status: active pilot
Updated: 2026-05-16
This guide wires the first real producer into artifact-store. A guide-board run
directory becomes one artifact package; State Hub records the package identity
and manifest digest, but never stores artifact bytes.
## One-Time Schema Registration
Start artifact-store and register the pilot metadata schema:
```sh
cd /home/worsch/artifact-store
export ARTIFACTSTORE_API_URL=http://127.0.0.1:8000
export ARTIFACTSTORE_API_TOKEN=dev-token
python3 scripts/register-guide-board-schema.py
```
The script posts this payload shape to `POST /metadata-schemas`:
```json
{
"slug": "guide-board.run.v1",
"json_schema": {
"$id": "artifactstore:schemas:guide-board.run.v1"
}
}
```
## Ingest A Run
The local CLI path opens the configured database and storage backend directly:
```sh
artifactstore guide-board ingest /tmp/guide-board-run \
--schema schemas/guide-board.run.v1.json
```
Output is JSON:
```json
{
"package_id": "00000000-0000-0000-0000-000000000000",
"manifest_digest": "blake3:...",
"file_count": 8,
"reused_existing": false
}
```
The helper is idempotent by guide-board `run_id`. Re-ingesting the same
finalized run returns the existing package id and manifest digest with
`reused_existing: true`.
## State Hub Linkage
After ingest, record a progress event with structured `detail`. This is the
canonical linkage shape:
```sh
curl -s -X POST "$STATE_HUB_URL/progress/" \
-H "Content-Type: application/json" \
-d '{
"event_type": "artifact_link",
"author": "artifact-store",
"workstream_id": "701c4d8c-5cf4-4a4a-ab60-1dcae53fe771",
"task_id": "bffa3573-4a1f-4c12-8c73-6d55bd8f6297",
"summary": "guide-board run <run_id> artifacts stored in artifact-store package <package_id>",
"detail": {
"producer": "guide-board",
"artifact_store_api_url": "http://127.0.0.1:8000",
"run_dir": "/tmp/guide-board-run",
"run_id": "<run_id>",
"target_profile_ref": "<target>",
"assessment_profile_ref": "<assessment>",
"result_status": "<status>",
"package_id": "<package_id>",
"manifest_digest": "<manifest_digest>",
"file_count": 8,
"retention_class": "release-evidence"
}
}'
```
Use the checked-in helper to build the same event from environment variables:
```sh
export STATE_HUB_URL=http://127.0.0.1:8000
export STATE_HUB_WORKSTREAM_ID=701c4d8c-5cf4-4a4a-ab60-1dcae53fe771
export STATE_HUB_TASK_ID=bffa3573-4a1f-4c12-8c73-6d55bd8f6297
export GUIDE_BOARD_RUN_DIR=/tmp/guide-board-run
export ARTIFACTSTORE_INGEST_RESULT_PATH=/tmp/artifactstore-guide-board-ingest.json
python3 scripts/link-guide-board-package.py
```
The helper posts only identifiers, summary metadata, and links. Artifact bytes
remain in artifact-store storage backends.
## Real Producer Smoke
This path uses the real guide-board core and the external `open-cmis-tck`
extension. It is expected to complete under five minutes on a developer
workstation once Python dependencies and local candidate prerequisites are in
place.
1. Produce a guide-board run:
```sh
cd /home/worsch/guide-board
mkdir -p /tmp/guide-board-artifact-store-smoke
PYTHONPATH=src python3 -m guide_board \
--extension-dir ../open-cmis-tck \
run \
--target ../open-cmis-tck/profiles/targets/kontextual-cmis-compat.json \
--assessment ../open-cmis-tck/profiles/assessments/cmis-browser-baseline.json \
--output-dir /tmp/guide-board-artifact-store-smoke/open-cmis-tck-baseline
```
2. Start artifact-store:
```sh
cd /home/worsch/artifact-store
cp .env.example .env
make migrate-fresh
make dev
```
3. Register the schema and ingest the run:
```sh
export ARTIFACTSTORE_API_TOKEN=dev-token
python3 scripts/register-guide-board-schema.py
artifactstore guide-board ingest \
/tmp/guide-board-artifact-store-smoke/open-cmis-tck-baseline \
--schema schemas/guide-board.run.v1.json \
> /tmp/artifactstore-guide-board-ingest.json
cat /tmp/artifactstore-guide-board-ingest.json
```
4. Verify the manifest:
```sh
PACKAGE_ID=$(python3 -c 'import json; print(json.load(open("/tmp/artifactstore-guide-board-ingest.json"))["package_id"])')
artifactstore manifest "$PACKAGE_ID"
```
5. Record State Hub linkage:
```sh
export STATE_HUB_URL=http://127.0.0.1:8000
export STATE_HUB_WORKSTREAM_ID=701c4d8c-5cf4-4a4a-ab60-1dcae53fe771
export STATE_HUB_TASK_ID=bffa3573-4a1f-4c12-8c73-6d55bd8f6297
export GUIDE_BOARD_RUN_DIR=/tmp/guide-board-artifact-store-smoke/open-cmis-tck-baseline
export ARTIFACTSTORE_INGEST_RESULT_PATH=/tmp/artifactstore-guide-board-ingest.json
python3 scripts/link-guide-board-package.py
```
To smoke the storage swap after enabling WP-0004 S3 settings, keep the same
guide-board ingest command and set
`ARTIFACTSTORE_STORAGE_BACKEND_ROUTES='guide-board:release-evidence=s3,*:*=local'`
before starting artifact-store.

View File

@@ -0,0 +1,95 @@
# STS Credential Vending Assessment for NetKingdom
Status: assessment complete (ARTIFACT-STORE-WP-0007 D7.3)
Date: 2026-07-02
Upstream baseline: `net-kingdom/docs/object-storage-sts-credential-vending.md`
(NK-WP-0007, 2026-05-18) — this assessment specializes that architecture for
artifact-store and records the current-state inventory it asks for.
## 1. Current object-storage credential inventory
| Consumer | Credential path today | Temporary-credential ready? |
| --- | --- | --- |
| artifact-store S3 backend | Static key pair via `ARTIFACTSTORE_S3_ACCESS_KEY_REF` / `ARTIFACTSTORE_S3_SECRET_KEY_REF` (file/env refs, no plaintext in config) | **No**`S3BackendConfig` has no `session_token` field and `aioboto3.Session` is constructed without `aws_session_token` (`src/artifactstore/storage/backends/s3.py`); STS credentials cannot be consumed until D7.4 lands |
| NetKingdom CNPG backups (`net-kingdom-pg-backup-s3`) | Documented as a static `kubectl create secret generic --from-literal` key pair in `sso-mfa/k8s/postgresql/README.md`; **not yet provisioned** — backups are parked "until object storage is available" | No — static by design today; should adopt the vending flow (or at minimum an ExternalSecret lane) when object storage is provisioned |
| MinIO compatibility harness (`make test-minio` / `test-minio-local`) | One-run generated throwaway root credentials, local container only | N/A — dev double, never a production path |
No other live S3/MinIO credential consumers were found in net-kingdom or
artifact-store. The most important inventory conclusion: **nothing is
production-live yet**, so the vending architecture can be adopted without a
migration burden — the CNPG backup lane should start on the target pattern
rather than shipping static keys first.
## 2. Can Keycloak / Authelia / local-identity act as the OIDC IdP for MinIO `AssumeRoleWithWebIdentity`?
MinIO's STS `AssumeRoleWithWebIdentity` accepts any OIDC JWT whose issuer is
configured in MinIO's identity-provider settings and whose claims map to a
MinIO policy. Assessment per NetKingdom issuer:
- **key-cape (lightweight mode)** — yes, and it is the preferred first
issuer: it already issues IAM Profile v0.2 tokens with OIDC discovery +
JWKS (proven by the Core Hub verifier integration test, CUST-WP-0025-T03).
MinIO consumes the discovery URL directly.
- **Keycloak (expanded mode)** — yes; standard, widely documented MinIO OIDC
pairing. This is the production/enterprise-federation issuer per the
NetKingdom baseline; not yet deployed (NK-WP-0001 Keycloak was cancelled as
superseded, so key-cape leads until enterprise federation is needed).
- **Authelia** — technically an OIDC provider, but it is the NetKingdom
session/portal SSO layer and does not issue IAM Profile v0.2 claims
(`tenant`, `principal_type`, `assurance`). Using it as a storage IdP would
bypass the IAM Profile contract. **Do not use** as the vending issuer.
- **local-identity** — dev/bootstrap contexts only, per the baseline's
local-dev restrictions: only explicitly configured dev vending instances
may accept it, and minted credentials must be confined to local/sandbox
object stores.
Important nuance from the baseline: consumers should not hit MinIO STS
directly with raw IdP tokens. The **credential-vending service** verifies the
IAM Profile and asks **flex-auth** for the decision first; MinIO's own
claim-to-policy mapping is then deliberately coarse (one policy per
tenant/prefix class), keeping authorization in flex-auth rather than in MinIO
policy JSON.
## 3. Target architecture (artifact-store specialization)
Follow the NetKingdom baseline flow (IAM Profile token → vending service →
flex-auth decision → backend exchange → temporary credentials). The
artifact-store-specific bindings:
| Element | Binding |
| --- | --- |
| Issuer | key-cape lightweight mode first; Keycloak expanded mode when enterprise federation arrives; local-identity for sandbox only |
| Audience | the credential-vending service audience (not artifact-store, not MinIO) — `aud` per IAM Profile v0.2 |
| Role/policy mapping | flex-auth vocabulary from the baseline: tenant, protected-system=`object-storage`, bucket, prefix, actions (`read`/`write`/`list`), TTL; MinIO side keeps one coarse policy per tenant-prefix class |
| Expiration | default lease 1560 min with refresh-before-expiry + jitter in the consumer; TTL bounds enforced by flex-auth (proven pattern: `ttl_out_of_bounds` denial in the FLEX-WP-0007 smoke) |
| Revocation | short TTLs are the primary control; for immediate cuts, disable the MinIO policy or the vending grant; OpenBao lease revocation applies only to broker-held parent material |
| Audit | vending service emits the baseline's audit event (issuer, sub, tenant, decision id, backend, TTL, non-secret request ids); OpenBao audit covers parent-credential access |
| Break-glass | platform-control-plane path per the baseline: short-lived, post-event review record mandatory; never a tenant-plane shortcut |
### artifact-store consumer work (feeds D7.4)
1. Add `session_token: str | None` to `S3BackendConfig` and pass
`aws_session_token` into `aioboto3.Session`.
2. Add `ARTIFACTSTORE_S3_SESSION_TOKEN_REF` (and optionally
`ARTIFACTSTORE_S3_CREDENTIAL_EXPIRATION_REF`) alongside the existing refs,
with atomic refresh of all three values.
3. Support the delivery modes from the baseline in priority order: mounted
files refreshed by a controller/sidecar first (fits the current `_REF`
file pattern), `credential_process` for CLI/batch use later.
### Sequencing recommendation
1. D7.4: session-token consumer support (small, local, no deployment gate).
2. Vending service + flex-auth vocabulary (NetKingdom/flex-auth owned;
artifact-store is a consumer, not the owner).
3. Wire the CNPG backup lane and artifact-store deployment to the vending
flow when Railiance provisions the production object store — do not ship
static production keys in the interim.
## Non-goals confirmed
- artifact-store does not own identity, authorization, or secret custody
(baseline ownership table).
- OpenBao is not the object-storage authorization engine; it holds parent
material and audit only.
- MinIO policy JSON is not the canonical tenant policy model; flex-auth is.

View File

@@ -48,6 +48,9 @@ dev = [
postgres = [
"psycopg[binary] >= 3.2",
]
s3 = [
"aioboto3 >= 13.0",
]
[project.scripts]
artifactstore = "artifactstore.cli:app"

12
registry/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Capability Registry
Markdown-first capability index for federation and reuse planning.
## Authoring
1. Copy a capability entry template (see reuse-surface `templates/capability-entry.template.md`).
2. Add the row to `indexes/capabilities.yaml`.
3. Run `reuse-surface validate` from a checkout with the CLI installed.
4. Merge to `main` and verify publish with `reuse-surface establish --publish-check`.
Federation contract: reuse-surface `docs/RegistryFederation.md`.

View File

View File

@@ -0,0 +1,4 @@
version: 1
updated: '2026-06-16'
domain: helix_forge
capabilities: []

View File

@@ -0,0 +1,42 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "artifactstore:schemas:guide-board.run.v1",
"title": "Guide-board run metadata",
"type": "object",
"additionalProperties": false,
"required": [
"run_id",
"target_profile_ref",
"assessment_profile_ref",
"result_status",
"source_commits",
"report_paths",
"evidence_counts",
"finding_counts"
],
"properties": {
"run_id": { "type": "string", "minLength": 1 },
"target_profile_ref": { "type": "string", "minLength": 1 },
"assessment_profile_ref": { "type": "string", "minLength": 1 },
"result_status": { "type": "string", "minLength": 1 },
"source_commits": {
"type": "object",
"additionalProperties": {
"type": "string",
"minLength": 7
}
},
"report_paths": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"evidence_counts": {
"type": "object",
"additionalProperties": { "type": "integer", "minimum": 0 }
},
"finding_counts": {
"type": "object",
"additionalProperties": { "type": "integer", "minimum": 0 }
}
}
}

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Record guide-board artifact package linkage in State Hub."""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
def main() -> None:
state_hub_url = _env("STATE_HUB_URL", "http://127.0.0.1:8000").rstrip("/")
artifact_api_url = _env("ARTIFACTSTORE_API_URL", "http://127.0.0.1:8000").rstrip("/")
run_dir = Path(_required("GUIDE_BOARD_RUN_DIR"))
run_json = _read_json(run_dir / "run.json")
retention_summary = _read_json(run_dir / "retention-summary.json")
ingest_result = _ingest_result()
package_id = _env("ARTIFACTSTORE_PACKAGE_ID") or _required_from(
ingest_result,
"package_id",
"ARTIFACTSTORE_PACKAGE_ID",
)
manifest_digest = _env("ARTIFACTSTORE_MANIFEST_DIGEST") or _required_from(
ingest_result,
"manifest_digest",
"ARTIFACTSTORE_MANIFEST_DIGEST",
)
run_id = _env("GUIDE_BOARD_RUN_ID") or str(
run_json.get("run_id") or run_json.get("id") or retention_summary.get("run_id")
)
summary = retention_summary.get("summary", {})
if not isinstance(summary, dict):
summary = {}
result_status = _env("GUIDE_BOARD_RESULT_STATUS") or str(
run_json.get("result_status") or run_json.get("status") or summary.get("status")
)
detail: dict[str, Any] = {
"producer": "guide-board",
"artifact_store_api_url": artifact_api_url,
"run_dir": str(run_dir),
"run_id": run_id,
"target_profile_ref": str(run_json["target_profile_ref"]),
"assessment_profile_ref": str(run_json["assessment_profile_ref"]),
"result_status": result_status,
"package_id": package_id,
"manifest_digest": manifest_digest,
}
if "file_count" in ingest_result:
detail["file_count"] = ingest_result["file_count"]
retention_class = _env("ARTIFACTSTORE_RETENTION_CLASS")
if retention_class:
detail["retention_class"] = retention_class
payload: dict[str, Any] = {
"event_type": _env("STATE_HUB_EVENT_TYPE", "artifact_link"),
"author": _env("STATE_HUB_AUTHOR", "artifact-store"),
"summary": _env(
"STATE_HUB_SUMMARY",
f"guide-board run {run_id} artifacts stored in artifact-store package {package_id}",
),
"detail": detail,
}
for field, env_name in (
("topic_id", "STATE_HUB_TOPIC_ID"),
("workstream_id", "STATE_HUB_WORKSTREAM_ID"),
("task_id", "STATE_HUB_TASK_ID"),
("session_id", "STATE_HUB_SESSION_ID"),
):
value = _env(env_name)
if value:
payload[field] = value
request = urllib.request.Request(
f"{state_hub_url}/progress/",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
print(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail_text = exc.read().decode("utf-8", errors="replace")
raise SystemExit(f"HTTP {exc.code}: {detail_text}") from exc
def _env(name: str, default: str = "") -> str:
return os.environ.get(name, default)
def _required(name: str) -> str:
value = _env(name)
if not value:
raise SystemExit(f"missing required environment variable: {name}")
return value
def _required_from(payload: dict[str, Any], key: str, env_name: str) -> str:
value = payload.get(key)
if isinstance(value, str) and value:
return value
raise SystemExit(f"missing {key!r}; set {env_name} or ARTIFACTSTORE_INGEST_RESULT_PATH")
def _ingest_result() -> dict[str, Any]:
raw_json = _env("ARTIFACTSTORE_INGEST_RESULT_JSON")
if raw_json:
payload = json.loads(raw_json)
if not isinstance(payload, dict):
raise SystemExit("ARTIFACTSTORE_INGEST_RESULT_JSON must be a JSON object")
return payload
result_path = _env("ARTIFACTSTORE_INGEST_RESULT_PATH")
if result_path:
return _read_json(Path(result_path))
return {}
def _read_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as fh:
payload = json.load(fh)
if not isinstance(payload, dict):
raise SystemExit(f"{path} must contain a JSON object")
return payload
if __name__ == "__main__":
main()

113
scripts/minio_local_smoke.sh Executable file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# minio_local_smoke.sh — deterministic local MinIO fixture for `make test-minio`
# (ARTIFACT-STORE-WP-0007 D7.2)
#
# Starts a throwaway MinIO container with generated one-run credentials,
# creates a smoke bucket, runs the live compatibility tests against it, and
# tears the container down. No external endpoint, no persistent credentials.
#
# Usage:
# bash scripts/minio_local_smoke.sh # or: make test-minio-local
#
# Environment overrides:
# MINIO_IMAGE (default: minio/minio:latest)
# MINIO_PORT (default: 19000, bound to 127.0.0.1 only)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MINIO_IMAGE="${MINIO_IMAGE:-minio/minio:latest}"
MINIO_PORT="${MINIO_PORT:-19000}"
CONTAINER="artifactstore-minio-smoke-$$"
BUCKET="artifactstore-smoke"
ACCESS_KEY="smoke-$(openssl rand -hex 8)"
SECRET_KEY="$(openssl rand -hex 24)"
cleanup() { docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; }
trap cleanup EXIT
echo "[minio-smoke] starting $MINIO_IMAGE on 127.0.0.1:$MINIO_PORT ..."
docker run -d --name "$CONTAINER" \
-p "127.0.0.1:${MINIO_PORT}:9000" \
-e MINIO_ROOT_USER="$ACCESS_KEY" \
-e MINIO_ROOT_PASSWORD="$SECRET_KEY" \
"$MINIO_IMAGE" server /data >/dev/null
for _ in $(seq 1 30); do
if curl -sf "http://127.0.0.1:${MINIO_PORT}/minio/health/live" >/dev/null; then
break
fi
sleep 1
done
curl -sf "http://127.0.0.1:${MINIO_PORT}/minio/health/live" >/dev/null \
|| { echo "[minio-smoke] ERROR: MinIO did not become healthy" >&2; exit 1; }
echo "[minio-smoke] health/live OK"
docker exec "$CONTAINER" sh -c \
'mc alias set local http://127.0.0.1:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null && mc mb local/'"$BUCKET" >/dev/null
echo "[minio-smoke] bucket $BUCKET created"
cd "$REPO_ROOT"
ARTIFACTSTORE_MINIO_ENDPOINT_URL="http://127.0.0.1:${MINIO_PORT}" \
ARTIFACTSTORE_MINIO_ACCESS_KEY="$ACCESS_KEY" \
ARTIFACTSTORE_MINIO_SECRET_KEY="$SECRET_KEY" \
ARTIFACTSTORE_MINIO_BUCKET="$BUCKET" \
make test-minio
echo "[minio-smoke] static-credential compatibility PASS"
# ── STS leg (D7.4): temporary credentials via MinIO AssumeRole ────────────────
# Root credentials cannot call AssumeRole, so mint a scoped user first.
STS_USER="sts-$(openssl rand -hex 6)"
STS_USER_SECRET="$(openssl rand -hex 24)"
docker exec -e STS_USER="$STS_USER" -e STS_USER_SECRET="$STS_USER_SECRET" "$CONTAINER" sh -c \
'mc admin user add local "$STS_USER" "$STS_USER_SECRET" >/dev/null && mc admin policy attach local readwrite --user "$STS_USER" >/dev/null'
echo "[minio-smoke] scoped user created; requesting temporary credentials via STS AssumeRole"
STS_JSON="$(
STS_ENDPOINT="http://127.0.0.1:${MINIO_PORT}" \
STS_USER="$STS_USER" STS_USER_SECRET="$STS_USER_SECRET" \
uv run --all-extras python - <<'PY'
import json
import os
import boto3
sts = boto3.client(
"sts",
endpoint_url=os.environ["STS_ENDPOINT"],
aws_access_key_id=os.environ["STS_USER"],
aws_secret_access_key=os.environ["STS_USER_SECRET"],
region_name="us-east-1",
)
creds = sts.assume_role(
RoleArn="arn:minio:iam:::role/dummy",
RoleSessionName="artifactstore-d74-smoke",
DurationSeconds=900,
)["Credentials"]
print(
json.dumps(
{
"AccessKeyId": creds["AccessKeyId"],
"SecretAccessKey": creds["SecretAccessKey"],
"SessionToken": creds["SessionToken"],
}
)
)
PY
)"
TEMP_ACCESS_KEY="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["AccessKeyId"])' "$STS_JSON")"
TEMP_SECRET_KEY="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["SecretAccessKey"])' "$STS_JSON")"
TEMP_SESSION_TOKEN="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["SessionToken"])' "$STS_JSON")"
ARTIFACTSTORE_MINIO_ENDPOINT_URL="http://127.0.0.1:${MINIO_PORT}" \
ARTIFACTSTORE_MINIO_ACCESS_KEY="$TEMP_ACCESS_KEY" \
ARTIFACTSTORE_MINIO_SECRET_KEY="$TEMP_SECRET_KEY" \
ARTIFACTSTORE_MINIO_SESSION_TOKEN="$TEMP_SESSION_TOKEN" \
ARTIFACTSTORE_MINIO_BUCKET="$BUCKET" \
make test-minio
echo "[minio-smoke] temporary-credential (STS session token) compatibility PASS"
echo "[minio-smoke] PASS — live MinIO static + STS round-trip/range/multipart compatibility verified"

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Register the guide-board pilot metadata schema through the HTTP API."""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from pathlib import Path
SCHEMA_SLUG = "guide-board.run.v1"
def main() -> None:
api_url = os.environ.get("ARTIFACTSTORE_API_URL", "http://127.0.0.1:8000").rstrip("/")
token = os.environ["ARTIFACTSTORE_API_TOKEN"]
schema_path = Path(
os.environ.get("ARTIFACTSTORE_GUIDE_BOARD_SCHEMA", "schemas/guide-board.run.v1.json")
)
payload = {
"slug": SCHEMA_SLUG,
"json_schema": json.loads(schema_path.read_text(encoding="utf-8")),
}
request = urllib.request.Request(
f"{api_url}/metadata-schemas",
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
print(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise SystemExit(f"HTTP {exc.code}: {detail}") from exc
if __name__ == "__main__":
main()

View File

@@ -1,41 +1,144 @@
"""FastAPI application — HTTP surface for the registry.
T014 ships a minimal app with two routes:
* ``GET /`` — service banner.
* ``GET /health`` — registry liveness + DB connectivity + storage backend.
Richer endpoints (package CRUD, file upload, manifest retrieval, event
stream) land in workplan WP-0002. The app is built through
:func:`create_app` so tests can inject their own settings.
"""
"""FastAPI application — HTTP surface for the registry."""
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Any
import asyncio
import re
import secrets
from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from email import policy
from email.parser import BytesParser
from http import HTTPStatus
from typing import Any, NoReturn, cast
from uuid import UUID, uuid4
from fastapi import Depends, FastAPI, Request
import cbor2
import jcs
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from artifactstore import __version__
from artifactstore.app import build_registry
from artifactstore.config import Settings
from artifactstore.registry import Registry
from artifactstore.config import Settings, get_settings
from artifactstore.events.model import Event
from artifactstore.registry import (
DuplicateRelativePathError,
FileNotFoundError,
FileRecord,
IllegalPackageStateError,
PackageNotFoundError,
PackageRecord,
Registry,
RetentionClassRecord,
RetentionStateError,
RetentionStateRecord,
)
__all__ = ["app", "create_app"]
_RANGE_RE = re.compile(r"^bytes=(?P<start>\d*)-(?P<end>\d*)$")
_CONTENT_RANGE_RE = re.compile(r"^bytes (?P<start>\d+)-(?P<end>\d+)/(?P<total>\d+|\*)$")
_MAX_EVENT_LIMIT = 500
class PackageCreate(BaseModel):
name: str = Field(min_length=1)
producer: str = Field(min_length=1)
subject: str = Field(min_length=1)
retention_class: str = Field(min_length=1)
metadata: dict[str, Any] = Field(default_factory=dict)
metadata_schema_slug: str | None = None
class MetadataSchemaCreate(BaseModel):
slug: str = Field(min_length=1)
json_schema: dict[str, Any]
class UploadCreate(BaseModel):
expected_size_bytes: int | None = Field(default=None, ge=0)
media_type: str | None = None
class UploadComplete(BaseModel):
package_id: UUID
relative_path: str = Field(min_length=1)
media_type: str | None = None
class RetentionExtensionCreate(BaseModel):
new_expires_at: datetime
reason: str = Field(min_length=1)
class RetentionHoldCreate(BaseModel):
reason: str = Field(min_length=1)
class RetentionHoldRelease(BaseModel):
reason: str = Field(min_length=1)
@dataclass(slots=True)
class MultipartFile:
filename: str
content_type: str
content: bytes
fields: dict[str, str]
@dataclass(slots=True)
class UploadSession:
upload_id: UUID
buffer: bytearray
expected_size_bytes: int | None
media_type: str | None
status: str = "open"
def get_registry(request: Request) -> Registry:
return request.app.state.registry # type: ignore[no-any-return]
def get_http_settings(request: Request) -> Settings:
return request.app.state.settings # type: ignore[no-any-return]
async def require_read_auth(
request: Request,
authorization: str | None = Header(default=None, alias="Authorization"),
) -> str:
settings = get_http_settings(request)
if settings.anon_read:
return "anonymous"
return _require_bearer(settings, authorization)
async def require_write_auth(
request: Request,
authorization: str | None = Header(default=None, alias="Authorization"),
) -> str:
settings = get_http_settings(request)
return _require_bearer(settings, authorization)
def create_app(settings: Settings | None = None) -> FastAPI:
"""Build the FastAPI app. Lifespan owns the registry instance."""
effective_settings = settings or get_settings()
@asynccontextmanager
async def lifespan(application: FastAPI) -> Any:
registry = build_registry(settings)
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
registry = build_registry(effective_settings)
application.state.registry = registry
application.state.settings = effective_settings
application.state.upload_sessions = {}
application.state.upload_lock = asyncio.Lock()
try:
yield
finally:
@@ -47,6 +150,26 @@ def create_app(settings: Settings | None = None) -> FastAPI:
lifespan=lifespan,
)
@application.exception_handler(HTTPException)
async def http_exception_handler(_request: Request, exc: HTTPException) -> JSONResponse:
return _problem_response(
status_code=exc.status_code,
title=_status_phrase(exc.status_code),
detail=str(exc.detail),
headers=exc.headers,
)
@application.exception_handler(RequestValidationError)
async def validation_exception_handler(
_request: Request,
exc: RequestValidationError,
) -> JSONResponse:
return _problem_response(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
title="Validation error",
detail=str(exc.errors()),
)
@application.get("/")
def root() -> dict[str, str]:
return {
@@ -59,7 +182,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
async def health(registry: Registry = Depends(get_registry)) -> dict[str, Any]:
db_ok, db_detail = await registry.db_health()
backend_status = await registry.backend_health()
overall = "ok" if db_ok and backend_status.healthy else "degraded"
failed_storage_locations = await registry.failed_storage_locations_count()
overall = (
"ok"
if db_ok and backend_status.healthy and failed_storage_locations == 0
else "degraded"
)
return {
"service": "artifact-store",
"version": __version__,
@@ -72,9 +200,679 @@ def create_app(settings: Settings | None = None) -> FastAPI:
"free_bytes": backend_status.free_bytes,
"total_bytes": backend_status.total_bytes,
},
"storage": {"failed_locations": failed_storage_locations},
}
@application.get("/backends")
async def backends(
_actor: str = Depends(require_read_auth),
registry: Registry = Depends(get_registry),
) -> dict[str, Any]:
statuses = await registry.backend_health_all()
return {
"backends": [
{
"backend_id": backend_status.backend_id,
"healthy": backend_status.healthy,
"detail": backend_status.detail,
"free_bytes": backend_status.free_bytes,
"total_bytes": backend_status.total_bytes,
}
for backend_status in statuses
]
}
@application.get("/retention-classes")
async def retention_classes(
_actor: str = Depends(require_read_auth),
registry: Registry = Depends(get_registry),
) -> dict[str, Any]:
classes = await registry.list_retention_classes()
return {"retention_classes": [_retention_class_dict(c) for c in classes]}
@application.post("/metadata-schemas", status_code=status.HTTP_201_CREATED)
async def register_metadata_schema(
body: MetadataSchemaCreate,
_actor: str = Depends(require_write_auth),
registry: Registry = Depends(get_registry),
) -> dict[str, Any]:
schema_id = await registry.register_metadata_schema(
slug=body.slug,
json_schema=body.json_schema,
)
schema = await registry.get_metadata_schema(body.slug)
return {
"id": str(schema_id),
"slug": schema.slug,
"json_schema": schema.json_schema,
"created_at": _iso(schema.created_at),
}
@application.post("/packages", status_code=status.HTTP_201_CREATED)
async def create_package(
body: PackageCreate,
actor: str = Depends(require_write_auth),
registry: Registry = Depends(get_registry),
) -> dict[str, Any]:
try:
package_id = await registry.create_package(
name=body.name,
producer=body.producer,
subject=body.subject,
retention_class=body.retention_class,
actor=actor,
metadata=body.metadata,
metadata_schema_slug=body.metadata_schema_slug,
)
return _package_dict(await registry.get_package(package_id))
except ValueError as exc:
_raise_problem(status.HTTP_400_BAD_REQUEST, str(exc))
@application.get("/packages")
async def list_packages(
producer: str | None = None,
subject: str | None = None,
retention_class: str | None = None,
metadata_key: str | None = None,
metadata_value: str | None = None,
_actor: str = Depends(require_read_auth),
registry: Registry = Depends(get_registry),
) -> dict[str, Any]:
packages = await registry.list_packages(
producer=producer,
subject=subject,
retention_class=retention_class,
metadata_key=metadata_key,
metadata_value=metadata_value,
)
return {"packages": [_package_dict(p) for p in packages]}
@application.get("/packages/{package_id}")
async def get_package(
package_id: UUID,
_actor: str = Depends(require_read_auth),
registry: Registry = Depends(get_registry),
) -> dict[str, Any]:
try:
return _package_dict(await registry.get_package(package_id))
except PackageNotFoundError as exc:
_raise_problem(status.HTTP_404_NOT_FOUND, str(exc))
@application.post("/packages/{package_id}/files", status_code=status.HTTP_201_CREATED)
async def upload_package_file(
package_id: UUID,
request: Request,
actor: str = Depends(require_write_auth),
registry: Registry = Depends(get_registry),
) -> dict[str, Any]:
multipart = await _read_single_file_multipart(request)
relative_path = multipart.fields.get("relative_path") or multipart.filename
media_type = multipart.fields.get("media_type") or multipart.content_type
try:
file_id = await registry.ingest_file(
package_id,
relative_path=relative_path,
media_type=media_type or "application/octet-stream",
stream=_bytes_stream(multipart.content),
actor=actor,
)
return _file_dict(await registry.get_file_metadata(file_id))
except PackageNotFoundError as exc:
_raise_problem(status.HTTP_404_NOT_FOUND, str(exc))
except DuplicateRelativePathError as exc:
_raise_problem(status.HTTP_409_CONFLICT, str(exc))
except IllegalPackageStateError as exc:
_raise_problem(status.HTTP_409_CONFLICT, str(exc))
@application.post("/packages/{package_id}/finalize")
async def finalize_package(
package_id: UUID,
actor: str = Depends(require_write_auth),
registry: Registry = Depends(get_registry),
) -> dict[str, Any]:
try:
await registry.finalize_package(package_id, actor=actor)
return _package_dict(await registry.get_package(package_id))
except PackageNotFoundError as exc:
_raise_problem(status.HTTP_404_NOT_FOUND, str(exc))
except IllegalPackageStateError as exc:
_raise_problem(status.HTTP_409_CONFLICT, str(exc))
@application.post("/packages/{package_id}/retention/extensions")
async def extend_retention(
package_id: UUID,
body: RetentionExtensionCreate,
actor: str = Depends(require_write_auth),
registry: Registry = Depends(get_registry),
) -> dict[str, Any]:
try:
state = await registry.extend_retention(
package_id,
new_expires_at=body.new_expires_at,
reason=body.reason,
actor=actor,
)
return _retention_state_dict(state)
except PackageNotFoundError as exc:
_raise_problem(status.HTTP_404_NOT_FOUND, str(exc))
except RetentionStateError as exc:
_raise_problem(status.HTTP_409_CONFLICT, str(exc))
@application.post("/packages/{package_id}/retention/holds", status_code=status.HTTP_201_CREATED)
async def apply_hold(
package_id: UUID,
body: RetentionHoldCreate,
actor: str = Depends(require_write_auth),
registry: Registry = Depends(get_registry),
) -> dict[str, Any]:
try:
hold_id = await registry.apply_retention_hold(
package_id,
reason=body.reason,
actor=actor,
)
state = await registry.get_retention_state(package_id)
return {"hold_id": str(hold_id), "retention": _retention_state_dict(state)}
except PackageNotFoundError as exc:
_raise_problem(status.HTTP_404_NOT_FOUND, str(exc))
except RetentionStateError as exc:
_raise_problem(status.HTTP_409_CONFLICT, str(exc))
@application.post("/packages/{package_id}/retention/holds/{hold_id}/release")
async def release_hold(
package_id: UUID,
hold_id: UUID,
body: RetentionHoldRelease,
actor: str = Depends(require_write_auth),
registry: Registry = Depends(get_registry),
) -> dict[str, Any]:
try:
state = await registry.release_retention_hold(
package_id,
hold_id,
reason=body.reason,
actor=actor,
)
return _retention_state_dict(state)
except PackageNotFoundError as exc:
_raise_problem(status.HTTP_404_NOT_FOUND, str(exc))
except RetentionStateError as exc:
_raise_problem(status.HTTP_409_CONFLICT, str(exc))
@application.get("/packages/{package_id}/retention/history")
async def retention_history(
package_id: UUID,
request: Request,
_actor: str = Depends(require_read_auth),
registry: Registry = Depends(get_registry),
) -> Response:
try:
history = await registry.retention_history(package_id)
except PackageNotFoundError as exc:
_raise_problem(status.HTTP_404_NOT_FOUND, str(exc))
payload = {"events": [_event_dict(event) for event in history]}
accept = request.headers.get("accept", "")
if "application/cbor" in accept:
return Response(
content=cbor2.dumps(payload, canonical=True),
media_type="application/cbor",
)
return Response(content=jcs.canonicalize(payload), media_type="application/json")
@application.get("/packages/{package_id}/manifest")
async def get_manifest_cbor(
package_id: UUID,
_actor: str = Depends(require_read_auth),
registry: Registry = Depends(get_registry),
) -> Response:
try:
payload = await registry.get_manifest_bytes(package_id, format="cbor")
except PackageNotFoundError as exc:
_raise_problem(status.HTTP_404_NOT_FOUND, str(exc))
return Response(content=payload, media_type="application/cbor")
@application.get("/packages/{package_id}/manifest.json")
async def get_manifest_json(
package_id: UUID,
_actor: str = Depends(require_read_auth),
registry: Registry = Depends(get_registry),
) -> Response:
try:
payload = await registry.get_manifest_bytes(package_id, format="json")
except PackageNotFoundError as exc:
_raise_problem(status.HTTP_404_NOT_FOUND, str(exc))
return Response(content=payload, media_type="application/json")
@application.get("/files/{file_id}")
async def get_file_metadata(
file_id: UUID,
_actor: str = Depends(require_read_auth),
registry: Registry = Depends(get_registry),
) -> dict[str, Any]:
try:
return _file_dict(await registry.get_file_metadata(file_id))
except FileNotFoundError as exc:
_raise_problem(status.HTTP_404_NOT_FOUND, str(exc))
@application.get("/files/{file_id}/download")
async def download_file(
file_id: UUID,
range_header: str | None = Header(default=None, alias="Range"),
if_none_match: str | None = Header(default=None, alias="If-None-Match"),
_actor: str = Depends(require_read_auth),
registry: Registry = Depends(get_registry),
) -> Response:
try:
file_record = await registry.get_file_metadata(file_id)
except FileNotFoundError as exc:
_raise_problem(status.HTTP_404_NOT_FOUND, str(exc))
etag = _etag(file_record.content_address)
headers = {
"Accept-Ranges": "bytes",
"ETag": etag,
"Content-Type": file_record.media_type,
}
if if_none_match and _etag_matches(if_none_match, file_record.content_address):
return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers=headers)
byte_range = _parse_range(range_header, file_record.size_bytes)
status_code = status.HTTP_200_OK
if byte_range is None:
headers["Content-Length"] = str(file_record.size_bytes)
else:
start, end = byte_range
headers["Content-Range"] = f"bytes {start}-{end}/{file_record.size_bytes}"
headers["Content-Length"] = str(end - start + 1)
status_code = status.HTTP_206_PARTIAL_CONTENT
stream = await registry.get_file(file_id, byte_range=byte_range)
return StreamingResponse(stream, status_code=status_code, headers=headers)
@application.post("/uploads", status_code=status.HTTP_201_CREATED)
async def open_upload(
body: UploadCreate,
request: Request,
_actor: str = Depends(require_write_auth),
) -> dict[str, Any]:
upload_id = uuid4()
session = UploadSession(
upload_id=upload_id,
buffer=bytearray(),
expected_size_bytes=body.expected_size_bytes,
media_type=body.media_type,
)
async with request.app.state.upload_lock:
request.app.state.upload_sessions[str(upload_id)] = session
return {
"upload_id": str(upload_id),
"status": session.status,
"content_upload_url": f"/uploads/{upload_id}",
}
@application.patch("/uploads/{upload_id}")
async def patch_upload(
upload_id: UUID,
request: Request,
content_range: str | None = Header(default=None, alias="Content-Range"),
_actor: str = Depends(require_write_auth),
) -> dict[str, Any]:
if content_range is None:
_raise_problem(status.HTTP_400_BAD_REQUEST, "Content-Range header is required")
body = await request.body()
start, end, total = _parse_content_range(content_range)
if end - start + 1 != len(body):
_raise_problem(
status.HTTP_400_BAD_REQUEST,
"Content-Range length does not match request body length",
)
async with request.app.state.upload_lock:
session = _upload_session(request, upload_id)
if start != len(session.buffer):
_raise_problem(
status.HTTP_409_CONFLICT,
f"upload offset mismatch: expected {len(session.buffer)}, got {start}",
)
session.buffer.extend(body)
if total is not None and len(session.buffer) > total:
_raise_problem(status.HTTP_400_BAD_REQUEST, "upload exceeds declared total size")
session.status = (
"uploaded" if total is not None and len(session.buffer) == total else "open"
)
return {
"upload_id": str(upload_id),
"status": session.status,
"received_bytes": len(session.buffer),
"expected_size_bytes": total,
}
@application.post("/uploads/{upload_id}/complete", status_code=status.HTTP_201_CREATED)
async def complete_upload(
upload_id: UUID,
body: UploadComplete,
request: Request,
actor: str = Depends(require_write_auth),
registry: Registry = Depends(get_registry),
) -> dict[str, Any]:
async with request.app.state.upload_lock:
session = _upload_session(request, upload_id)
payload = bytes(session.buffer)
media_type = body.media_type or session.media_type or "application/octet-stream"
try:
file_id = await registry.ingest_file(
body.package_id,
relative_path=body.relative_path,
media_type=media_type,
stream=_bytes_stream(payload),
actor=actor,
)
except PackageNotFoundError as exc:
_raise_problem(status.HTTP_404_NOT_FOUND, str(exc))
except DuplicateRelativePathError as exc:
_raise_problem(status.HTTP_409_CONFLICT, str(exc))
except IllegalPackageStateError as exc:
_raise_problem(status.HTTP_409_CONFLICT, str(exc))
async with request.app.state.upload_lock:
request.app.state.upload_sessions.pop(str(upload_id), None)
return _file_dict(await registry.get_file_metadata(file_id))
@application.get("/events")
async def events(
request: Request,
since: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=_MAX_EVENT_LIMIT),
wait_seconds: float = Query(default=1.0, ge=0.0, le=30.0),
_actor: str = Depends(require_read_auth),
registry: Registry = Depends(get_registry),
) -> Response:
batch = await _long_poll_events(
registry,
since_sequence=since,
limit=limit,
wait_seconds=wait_seconds,
)
payload = {"events": [_event_dict(e) for e in batch]}
accept = request.headers.get("accept", "")
if "application/json" in accept:
return Response(content=jcs.canonicalize(payload), media_type="application/json")
return Response(content=cbor2.dumps(payload, canonical=True), media_type="application/cbor")
return application
def _require_bearer(settings: Settings, authorization: str | None) -> str:
tokens = settings.bearer_tokens
if not tokens:
_raise_problem(
status.HTTP_401_UNAUTHORIZED,
"No bearer tokens are configured",
headers={"WWW-Authenticate": "Bearer"},
)
if authorization is None or not authorization.lower().startswith("bearer "):
_raise_problem(
status.HTTP_401_UNAUTHORIZED,
"Bearer token required",
headers={"WWW-Authenticate": "Bearer"},
)
supplied = authorization.partition(" ")[2].strip()
if not any(secrets.compare_digest(supplied, token) for token in tokens):
_raise_problem(
status.HTTP_401_UNAUTHORIZED,
"Invalid bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
return "bearer"
def _raise_problem(
status_code: int,
detail: str,
*,
headers: Mapping[str, str] | None = None,
) -> NoReturn:
raise HTTPException(status_code=status_code, detail=detail, headers=headers)
def _problem_response(
*,
status_code: int,
title: str,
detail: str,
headers: Mapping[str, str] | None = None,
) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content={
"type": "about:blank",
"title": title,
"status": status_code,
"detail": detail,
},
media_type="application/problem+json",
headers=headers,
)
def _status_phrase(status_code: int) -> str:
try:
return HTTPStatus(status_code).phrase
except ValueError:
return "HTTP error"
def _package_dict(record: PackageRecord) -> dict[str, Any]:
return {
"id": str(record.id),
"name": record.name,
"producer": record.producer,
"subject": record.subject,
"retention_class": record.retention_class,
"metadata_schema_id": str(record.metadata_schema_id) if record.metadata_schema_id else None,
"metadata": record.metadata,
"status": record.status,
"manifest_digest": f"blake3:{record.manifest_digest_hex}"
if record.manifest_digest_hex
else None,
"created_at": _iso(record.created_at),
"finalized_at": _iso(record.finalized_at),
"expires_at": _iso(record.expires_at),
"last_event_sequence": record.last_event_sequence,
}
def _file_dict(record: FileRecord) -> dict[str, Any]:
return {
"id": str(record.id),
"package_id": str(record.package_id),
"relative_path": record.relative_path,
"media_type": record.media_type,
"size_bytes": record.size_bytes,
"digest_algorithm": record.digest_algorithm,
"digest_primary_hex": record.digest_primary_hex,
"digest_sha256_hex": record.digest_sha256_hex,
"content_address": record.content_address,
"created_at": _iso(record.created_at),
"storage": {
"backend_id": record.backend_id,
"object_key": record.object_key,
"retrieval_tier": record.retrieval_tier,
"status": record.storage_status,
},
}
def _retention_class_dict(record: RetentionClassRecord) -> dict[str, Any]:
return {
"class_id": record.class_id,
"default_duration_seconds": record.default_duration_seconds,
"deletion_strategy": record.deletion_strategy,
}
def _retention_state_dict(record: RetentionStateRecord) -> dict[str, Any]:
return {
"package_id": str(record.package_id),
"current_expires_at": _iso(record.current_expires_at),
"effective_class": record.effective_class,
"active_hold_id": str(record.active_hold_id) if record.active_hold_id else None,
"eligible_for_deletion": record.eligible_for_deletion,
}
def _event_dict(event: Event) -> dict[str, Any]:
payload = cbor2.loads(event.payload)
return {
"sequence": event.sequence,
"created_at": _iso(event.created_at),
"event_type": event.event_type,
"subject_kind": event.subject_kind,
"subject_id": str(event.subject_id) if event.subject_id else None,
"actor": event.actor,
"payload_digest_hex": event.payload_digest.hex(),
"payload": payload,
}
def _iso(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.isoformat()
async def _read_single_file_multipart(request: Request) -> MultipartFile:
content_type = request.headers.get("content-type", "")
if not content_type.lower().startswith("multipart/form-data"):
_raise_problem(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "multipart/form-data is required")
raw = await request.body()
message = cast(
Any,
BytesParser(policy=policy.default).parsebytes(
f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode() + raw
),
)
if not message.is_multipart():
_raise_problem(status.HTTP_400_BAD_REQUEST, "request body is not multipart")
fields: dict[str, str] = {}
chosen: tuple[str, str, bytes] | None = None
for part in message.iter_parts():
if part.get_content_disposition() != "form-data":
continue
name = cast(str | None, part.get_param("name", header="content-disposition"))
filename = part.get_filename()
payload = cast(bytes, part.get_payload(decode=True) or b"")
if filename is None:
if name is not None:
fields[name] = payload.decode(part.get_content_charset() or "utf-8")
continue
if chosen is None or name == "file":
chosen = (filename, part.get_content_type(), payload)
if chosen is None:
_raise_problem(status.HTTP_400_BAD_REQUEST, "multipart file part is required")
filename, part_content_type, payload = chosen
return MultipartFile(
filename=filename,
content_type=part_content_type or "application/octet-stream",
content=payload,
fields=fields,
)
async def _bytes_stream(data: bytes) -> AsyncIterator[bytes]:
yield data
def _etag(content_address: str) -> str:
return f'"{content_address}"'
def _etag_matches(header_value: str, content_address: str) -> bool:
candidates = [v.strip() for v in header_value.split(",")]
return (
"*" in candidates
or content_address in candidates
or _etag(content_address) in candidates
)
def _parse_range(header_value: str | None, size_bytes: int) -> tuple[int, int] | None:
if header_value is None:
return None
match = _RANGE_RE.match(header_value.strip())
if match is None:
_raise_problem(
status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE,
"only a single bytes range is supported",
)
start_raw = match.group("start")
end_raw = match.group("end")
if size_bytes <= 0:
_raise_problem(status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE, "empty file has no range")
if start_raw == "":
if end_raw == "":
_raise_problem(status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE, "invalid range")
suffix = int(end_raw)
if suffix <= 0:
_raise_problem(status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE, "invalid suffix range")
start = max(size_bytes - suffix, 0)
end = size_bytes - 1
else:
start = int(start_raw)
end = int(end_raw) if end_raw else size_bytes - 1
if start >= size_bytes or end < start:
_raise_problem(status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE, "range not satisfiable")
return start, min(end, size_bytes - 1)
def _parse_content_range(header_value: str) -> tuple[int, int, int | None]:
match = _CONTENT_RANGE_RE.match(header_value.strip())
if match is None:
_raise_problem(status.HTTP_400_BAD_REQUEST, "invalid Content-Range header")
start = int(match.group("start"))
end = int(match.group("end"))
total_raw = match.group("total")
total = None if total_raw == "*" else int(total_raw)
if end < start:
_raise_problem(status.HTTP_400_BAD_REQUEST, "invalid Content-Range bounds")
if total is not None and end >= total:
_raise_problem(status.HTTP_400_BAD_REQUEST, "Content-Range exceeds total size")
return start, end, total
def _upload_session(request: Request, upload_id: UUID) -> UploadSession:
session = request.app.state.upload_sessions.get(str(upload_id))
if session is None:
_raise_problem(status.HTTP_404_NOT_FOUND, f"upload not found: {upload_id}")
return session # type: ignore[no-any-return]
async def _long_poll_events(
registry: Registry,
*,
since_sequence: int,
limit: int,
wait_seconds: float,
) -> list[Event]:
batch = await registry.fetch_events(since_sequence=since_sequence, limit=limit)
if batch or wait_seconds == 0:
return batch
collected: list[Event] = []
async def collect() -> None:
async for event in registry.tail_events(
since_sequence=since_sequence,
poll_interval_seconds=0.05,
):
collected.append(event)
if len(collected) >= limit:
break
with suppress(TimeoutError):
await asyncio.wait_for(collect(), timeout=wait_seconds)
return collected
app = create_app()

View File

@@ -7,12 +7,15 @@ control-plane consumers stay thin (per ADR-0004).
from __future__ import annotations
from artifactstore.config import Settings, get_settings
from collections.abc import Callable
from artifactstore.config import Settings, get_settings, resolve_secret_ref
from artifactstore.dataplane import InProcessDataPlane
from artifactstore.db.engine import create_engine
from artifactstore.events import RegistryViewWriter
from artifactstore.registry import Registry
from artifactstore.storage import LocalBackend
from artifactstore.retention import RetentionPolicy
from artifactstore.storage import LocalBackend, S3Backend, S3BackendConfig, StorageBackend
__all__ = ["build_registry"]
@@ -21,7 +24,75 @@ def build_registry(settings: Settings | None = None) -> Registry:
"""Wire engine, local FS backend, in-process data plane, and registry."""
effective = settings or get_settings()
engine = create_engine(effective)
backend = LocalBackend(effective.storage_local_root, backend_id="local")
dataplane = InProcessDataPlane(backend)
backends = _build_backends(effective)
dataplane = InProcessDataPlane(
backends,
default_backend_id=effective.storage_default_backend,
)
view_writer = RegistryViewWriter()
return Registry(engine, dataplane, view_writer)
retention_policy = RetentionPolicy.from_toml(effective.retention_config_path)
return Registry(
engine,
dataplane,
view_writer,
retention_policy,
backend_selector=_backend_selector(effective),
)
def _build_backends(settings: Settings) -> dict[str, StorageBackend]:
configured = settings.configured_backend_ids or ("local",)
backends: dict[str, StorageBackend] = {}
if "local" in configured:
backends["local"] = LocalBackend(settings.storage_local_root, backend_id="local")
if "s3" in configured:
refs = (
settings.s3_access_key_ref,
settings.s3_secret_key_ref,
settings.s3_session_token_ref,
)
def _resolve_credentials() -> tuple[str | None, str | None, str | None]:
access_ref, secret_ref, token_ref = refs
return (
resolve_secret_ref(access_ref) if access_ref else None,
resolve_secret_ref(secret_ref) if secret_ref else None,
resolve_secret_ref(token_ref) if token_ref else None,
)
access_key, secret_key, session_token = _resolve_credentials()
# file: refs are re-read per client so an STS sidecar/controller can
# rotate all three values atomically without a process restart.
refreshable = any(ref.startswith("file:") for ref in refs if ref)
backends["s3"] = S3Backend(
S3BackendConfig(
endpoint_url=settings.s3_endpoint_url,
region=settings.s3_region,
bucket=settings.s3_bucket,
key_prefix=settings.s3_key_prefix,
access_key_id=access_key,
secret_access_key=secret_key,
session_token=session_token,
storage_class=settings.s3_storage_class or None,
sse=settings.s3_sse or None,
multipart_threshold_bytes=settings.s3_multipart_threshold_bytes,
multipart_chunk_bytes=settings.s3_multipart_chunk_bytes,
),
credentials_provider=_resolve_credentials if refreshable else None,
)
unknown = set(configured) - set(backends)
if unknown:
raise ValueError(f"unknown storage backend ids: {sorted(unknown)}")
return backends
def _backend_selector(settings: Settings) -> Callable[[str, str], str | None]:
routes = settings.backend_routes
def select(producer: str, retention_class: str) -> str | None:
for route in routes:
if route.matches(producer=producer, retention_class=retention_class):
return route.backend_id
return settings.storage_default_backend
return select

View File

@@ -10,9 +10,14 @@ from __future__ import annotations
import asyncio
import json
import mimetypes
import urllib.error
import urllib.request
import uuid
from pathlib import Path
from typing import Any
import click
import typer
from artifactstore import __version__
@@ -28,6 +33,12 @@ app = typer.Typer(
help="artifact-store: artifact registry and storage gateway",
no_args_is_help=True,
)
retention_app = typer.Typer(help="Retention lifecycle commands", no_args_is_help=True)
storage_app = typer.Typer(help="Storage backend commands", no_args_is_help=True)
guide_board_app = typer.Typer(help="Guide-board pilot commands", no_args_is_help=True)
app.add_typer(retention_app, name="retention")
app.add_typer(storage_app, name="storage")
app.add_typer(guide_board_app, name="guide-board")
@app.callback()
@@ -74,6 +85,181 @@ def health() -> None:
typer.echo(json.dumps(payload, indent=2))
@app.command()
def push(
directory: Path = typer.Argument(
...,
exists=True,
file_okay=False,
dir_okay=True,
readable=True,
help="Directory to push as one artifact package.",
),
producer: str = typer.Option(..., "--producer", help="Producer slug for the package."),
subject: str = typer.Option(..., "--subject", help="Subject identifier for the package."),
retention_class: str = typer.Option(
"raw-evidence",
"--retention-class",
help="Retention class id to apply.",
),
name: str | None = typer.Option(None, "--name", help="Package name; defaults to dir name."),
api_url: str | None = typer.Option(None, "--api-url", help="artifact-store base URL."),
token: str | None = typer.Option(None, "--token", help="Bearer token for the HTTP API."),
) -> None:
"""Push a directory through the HTTP API and finalize the package."""
settings = get_settings()
base_url = api_url or settings.api_url
bearer = token or settings.api_token
if not bearer:
raise click.ClickException("provide --token or ARTIFACTSTORE_API_TOKEN")
files = sorted(path for path in directory.rglob("*") if path.is_file())
package = _http_json(
"POST",
base_url,
"/packages",
bearer,
{
"name": name or directory.name,
"producer": producer,
"subject": subject,
"retention_class": retention_class,
"metadata": {"source_directory": str(directory)},
},
)
package_id = str(package["id"])
for path in files:
rel_path = path.relative_to(directory).as_posix()
media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
_http_multipart(
base_url,
f"/packages/{package_id}/files",
bearer,
fields={"relative_path": rel_path, "media_type": media_type},
file_field="file",
file_name=path.name,
file_content_type=media_type,
file_bytes=path.read_bytes(),
)
finalized = _http_json("POST", base_url, f"/packages/{package_id}/finalize", bearer, {})
typer.echo(
json.dumps(
{
"package_id": package_id,
"manifest_digest": finalized.get("manifest_digest"),
"files": len(files),
},
indent=2,
)
)
@app.command()
def manifest(
package_id: str = typer.Argument(..., help="Package UUID."),
api_url: str | None = typer.Option(None, "--api-url", help="artifact-store base URL."),
token: str | None = typer.Option(None, "--token", help="Bearer token for the HTTP API."),
) -> None:
"""Print a package manifest JSON projection from the HTTP API."""
settings = get_settings()
base_url = api_url or settings.api_url
bearer = token or settings.api_token
if not bearer:
raise click.ClickException("provide --token or ARTIFACTSTORE_API_TOKEN")
payload = _http_bytes(
"GET",
base_url,
f"/packages/{package_id}/manifest.json",
bearer,
headers={"Accept": "application/json"},
)
typer.echo(payload.decode("utf-8"))
@retention_app.command("sweep")
def retention_sweep() -> None:
"""Run the deletion-eligibility sweeper once against the configured DB."""
settings = get_settings()
marked = asyncio.run(_retention_sweep_async(settings))
typer.echo(json.dumps({"marked_package_ids": marked, "marked_count": len(marked)}, indent=2))
@retention_app.command("gc")
def retention_gc() -> None:
"""Run one garbage-collection pass for deletion-eligible packages."""
settings = get_settings()
results = asyncio.run(_garbage_collect_async(settings))
object_keys = {
(r["backend_id"], r["content_address"])
for r in results
if r["object_delete_attempted"]
}
deleted_object_keys = {
(r["backend_id"], r["content_address"])
for r in results
if r["object_delete_attempted"] and r["object_deleted"]
}
typer.echo(
json.dumps(
{
"released_location_count": len(results),
"delete_attempted_object_count": len(object_keys),
"deleted_object_count": len(deleted_object_keys),
"results": results,
},
indent=2,
)
)
@storage_app.command("verify")
def storage_verify(
backend: str | None = typer.Option(
None,
"--backend",
help="Backend id to verify; omit to verify every storage location.",
),
) -> None:
"""Re-read storage locations, recompute digests, and record verification events."""
settings = get_settings()
results = asyncio.run(_storage_verify_async(settings, backend_id=backend))
failed = [r for r in results if not r["verified"]]
typer.echo(
json.dumps(
{
"verified_count": len(results) - len(failed),
"failed_count": len(failed),
"results": results,
},
indent=2,
)
)
@guide_board_app.command("ingest")
def guide_board_ingest(
run_dir: Path = typer.Argument(
...,
exists=True,
file_okay=False,
dir_okay=True,
readable=True,
help="Guide-board run directory.",
),
schema_path: Path = typer.Option(
Path("schemas/guide-board.run.v1.json"),
"--schema",
help="Path to the guide-board metadata schema JSON.",
),
) -> None:
"""Ingest a guide-board run directory through the local registry."""
settings = get_settings()
result = asyncio.run(_guide_board_ingest_async(settings, run_dir, schema_path))
typer.echo(json.dumps(result, indent=2))
# ---- internals -------------------------------------------------------------
@@ -92,9 +278,14 @@ async def _health_async(settings: Settings) -> dict[str, Any]:
try:
db_ok, db_detail = await registry.db_health()
backend_status = await registry.backend_health()
failed_storage_locations = await registry.failed_storage_locations_count()
finally:
await registry.dispose()
overall = "ok" if db_ok and backend_status.healthy else "degraded"
overall = (
"ok"
if db_ok and backend_status.healthy and failed_storage_locations == 0
else "degraded"
)
return {
"service": "artifact-store",
"version": __version__,
@@ -107,8 +298,202 @@ async def _health_async(settings: Settings) -> dict[str, Any]:
"free_bytes": backend_status.free_bytes,
"total_bytes": backend_status.total_bytes,
},
"storage": {"failed_locations": failed_storage_locations},
}
async def _retention_sweep_async(settings: Settings) -> list[str]:
from artifactstore.app import build_registry
registry: Registry = build_registry(settings)
try:
marked = await registry.sweep_deletion_eligibility()
finally:
await registry.dispose()
return [str(package_id) for package_id in marked]
async def _garbage_collect_async(settings: Settings) -> list[dict[str, Any]]:
from artifactstore.app import build_registry
registry: Registry = build_registry(settings)
try:
results = await registry.collect_garbage()
finally:
await registry.dispose()
return [
{
"storage_location_id": str(result.storage_location_id),
"file_id": str(result.file_id),
"package_id": str(result.package_id),
"backend_id": result.backend_id,
"content_address": result.content_address,
"object_delete_attempted": result.object_delete_attempted,
"object_deleted": result.object_deleted,
"ref_count_before": result.ref_count_before,
"ref_count_after": result.ref_count_after,
}
for result in results
]
async def _storage_verify_async(
settings: Settings,
*,
backend_id: str | None,
) -> list[dict[str, Any]]:
from artifactstore.app import build_registry
registry: Registry = build_registry(settings)
try:
results = await registry.verify_storage_locations(backend_id=backend_id)
finally:
await registry.dispose()
return [
{
"storage_location_id": str(result.storage_location_id),
"file_id": str(result.file_id),
"backend_id": result.backend_id,
"content_address": result.content_address,
"verified": result.verified,
"mismatch": result.mismatch,
}
for result in results
]
async def _guide_board_ingest_async(
settings: Settings,
run_dir: Path,
schema_path: Path,
) -> dict[str, Any]:
from artifactstore.app import build_registry
from artifactstore.pilots.guide_board import GUIDE_BOARD_SCHEMA_SLUG, ingest_run
registry: Registry = build_registry(settings)
try:
schema = json.loads(schema_path.read_text(encoding="utf-8"))
if not isinstance(schema, dict):
raise click.BadParameter(f"schema must be a JSON object: {schema_path}")
await registry.register_metadata_schema(
slug=GUIDE_BOARD_SCHEMA_SLUG,
json_schema=schema,
)
result = await ingest_run(run_dir, registry=registry)
finally:
await registry.dispose()
return {
"package_id": result.package_id,
"manifest_digest": result.manifest_digest,
"file_count": result.file_count,
"reused_existing": result.reused_existing,
}
def _http_json(
method: str,
base_url: str,
path: str,
token: str,
payload: dict[str, Any],
) -> dict[str, Any]:
response = _http_bytes(
method,
base_url,
path,
token,
body=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", "Accept": "application/json"},
)
decoded = json.loads(response)
if not isinstance(decoded, dict):
raise click.ClickException(f"expected JSON object from {path}")
return decoded
def _http_multipart(
base_url: str,
path: str,
token: str,
*,
fields: dict[str, str],
file_field: str,
file_name: str,
file_content_type: str,
file_bytes: bytes,
) -> dict[str, Any]:
boundary = f"artifactstore-{uuid.uuid4().hex}"
body = bytearray()
for name, value in fields.items():
body.extend(f"--{boundary}\r\n".encode("ascii"))
body.extend(
f'Content-Disposition: form-data; name="{_quote_header_value(name)}"\r\n\r\n'.encode()
)
body.extend(value.encode())
body.extend(b"\r\n")
body.extend(f"--{boundary}\r\n".encode("ascii"))
body.extend(
(
f'Content-Disposition: form-data; name="{_quote_header_value(file_field)}"; '
f'filename="{_quote_header_value(file_name)}"\r\n'
f"Content-Type: {file_content_type}\r\n\r\n"
).encode()
)
body.extend(file_bytes)
body.extend(b"\r\n")
body.extend(f"--{boundary}--\r\n".encode("ascii"))
response = _http_bytes(
"POST",
base_url,
path,
token,
body=bytes(body),
headers={
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Accept": "application/json",
},
)
decoded = json.loads(response)
if not isinstance(decoded, dict):
raise click.ClickException(f"expected JSON object from {path}")
return decoded
def _http_bytes(
method: str,
base_url: str,
path: str,
token: str,
*,
body: bytes | None = None,
headers: dict[str, str] | None = None,
) -> bytes:
url = f"{base_url.rstrip('/')}/{path.lstrip('/')}"
effective_headers = dict(headers or {})
effective_headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(
url,
data=body,
headers=effective_headers,
method=method,
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
data = response.read()
if isinstance(data, bytes):
return data
return bytes(data)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise click.ClickException(f"HTTP {exc.code} from {path}: {detail}") from exc
except urllib.error.URLError as exc:
raise click.ClickException(f"could not reach {url}: {exc.reason}") from exc
def _quote_header_value(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
if __name__ == "__main__": # pragma: no cover
app()

View File

@@ -7,8 +7,32 @@ local development; see ``.env.example``.
from __future__ import annotations
from dataclasses import dataclass
from pydantic_settings import BaseSettings, SettingsConfigDict
__all__ = [
"BackendRoute",
"Settings",
"get_settings",
"parse_backend_routes",
"resolve_secret_ref",
]
@dataclass(frozen=True, slots=True)
class BackendRoute:
"""Route one producer/retention-class pair to a storage backend."""
producer: str
retention_class: str
backend_id: str
def matches(self, *, producer: str, retention_class: str) -> bool:
producer_match = self.producer == "*" or self.producer == producer
retention_match = self.retention_class == "*" or self.retention_class == retention_class
return producer_match and retention_match
class Settings(BaseSettings):
"""Top-level service configuration."""
@@ -23,6 +47,87 @@ class Settings(BaseSettings):
database_url: str = "sqlite+aiosqlite:///./var/artifactstore.db"
storage_local_root: str = "./var/storage"
log_level: str = "INFO"
auth_tokens: str = ""
anon_read: bool = False
api_url: str = "http://127.0.0.1:8000"
api_token: str = ""
retention_config_path: str = ""
retention_sweep_interval_seconds: int = 3600
storage_backends: str = "local"
storage_default_backend: str = "local"
storage_backend_routes: str = ""
s3_endpoint_url: str = ""
s3_region: str = "us-east-1"
s3_bucket: str = ""
s3_key_prefix: str = ""
s3_access_key_ref: str = ""
s3_secret_key_ref: str = ""
s3_session_token_ref: str = ""
s3_storage_class: str = ""
s3_sse: str = ""
s3_multipart_threshold_bytes: int = 64 * 1024 * 1024
s3_multipart_chunk_bytes: int = 8 * 1024 * 1024
@property
def bearer_tokens(self) -> frozenset[str]:
"""Configured shared-secret bearer tokens, parsed from CSV / newline text."""
return frozenset(
token.strip()
for token in self.auth_tokens.replace("\n", ",").split(",")
if token.strip()
)
@property
def configured_backend_ids(self) -> tuple[str, ...]:
return tuple(
backend_id.strip()
for backend_id in self.storage_backends.split(",")
if backend_id.strip()
)
@property
def backend_routes(self) -> tuple[BackendRoute, ...]:
return parse_backend_routes(self.storage_backend_routes)
def parse_backend_routes(value: str) -> tuple[BackendRoute, ...]:
"""Parse ``producer:retention_class=backend`` route entries."""
routes: list[BackendRoute] = []
for raw_entry in value.split(","):
entry = raw_entry.strip()
if not entry:
continue
selector, sep, backend_id = entry.partition("=")
if sep == "" or not backend_id.strip():
raise ValueError(f"invalid storage backend route: {entry!r}")
producer, selector_sep, retention_class = selector.partition(":")
if selector_sep == "":
raise ValueError(f"invalid storage backend route selector: {selector!r}")
routes.append(
BackendRoute(
producer=producer.strip() or "*",
retention_class=retention_class.strip() or "*",
backend_id=backend_id.strip(),
)
)
return tuple(routes)
def resolve_secret_ref(ref: str) -> str:
"""Resolve a secret reference from ``env:NAME`` or ``file:/path``."""
import os
from pathlib import Path
if ref.startswith("env:"):
name = ref.removeprefix("env:")
try:
return os.environ[name]
except KeyError as exc:
raise ValueError(f"environment variable {name!r} is not set") from exc
if ref.startswith("file:"):
path = Path(ref.removeprefix("file:"))
return path.read_text(encoding="utf-8").strip()
raise ValueError("secret references must use env:NAME or file:/path")
def get_settings() -> Settings:

View File

@@ -11,7 +11,7 @@ from __future__ import annotations
import os
import tempfile
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Mapping
from pathlib import Path
from artifactstore.dataplane.spi import (
@@ -39,22 +39,36 @@ _DEFAULT_CHUNK_SIZE = 64 * 1024
class InProcessDataPlane:
"""The v1 data plane: wraps one :class:`StorageBackend`."""
"""The v1 data plane: wraps one or more :class:`StorageBackend` instances."""
def __init__(
self,
backend: StorageBackend,
backend: StorageBackend | Mapping[str, StorageBackend],
*,
default_backend_id: str | None = None,
tmp_dir: str | os.PathLike[str] | None = None,
chunk_size: int = _DEFAULT_CHUNK_SIZE,
) -> None:
self._backend = backend
if isinstance(backend, Mapping):
self._backends = dict(backend)
if not self._backends:
raise ValueError("at least one storage backend is required")
self._default_backend_id = default_backend_id or sorted(self._backends)[0]
else:
self._backends = {backend.backend_id: backend}
self._default_backend_id = default_backend_id or backend.backend_id
if self._default_backend_id not in self._backends:
raise ValueError(f"unknown default backend: {self._default_backend_id!r}")
self._tmp_dir = Path(tmp_dir) if tmp_dir is not None else None
self._chunk_size = chunk_size
@property
def backend(self) -> StorageBackend:
return self._backend
return self._select_backend(None)
@property
def backends(self) -> Mapping[str, StorageBackend]:
return dict(self._backends)
async def ingest_stream(
self,
@@ -64,6 +78,7 @@ class InProcessDataPlane:
) -> IngestResult:
effective_hints = hints or IngestHints()
primary_name = effective_hints.primary_algorithm or PRIMARY_ALGORITHM
backend = self._select_backend(effective_hints.backend_id)
primary_h = get_algorithm(primary_name)()
sha_h = get_algorithm(INTEROP_ALGORITHM)()
@@ -88,7 +103,7 @@ class InProcessDataPlane:
sha_digest = Digest(algorithm=INTEROP_ALGORITHM, hex=sha_h.hexdigest())
content_address = primary_digest.content_address
receipt = await self._backend.put(
receipt = await backend.put(
content_address,
_file_chunks(tmp_path, self._chunk_size),
size_hint=size,
@@ -108,16 +123,23 @@ class InProcessDataPlane:
content_address: ContentAddress,
*,
byte_range: tuple[int, int] | None = None,
backend_id: str | None = None,
) -> AsyncIterator[bytes]:
return await self._backend.get(content_address, byte_range=byte_range)
return await self._select_backend(backend_id).get(content_address, byte_range=byte_range)
async def verify_object(self, content_address: ContentAddress) -> VerifyResult:
async def verify_object(
self,
content_address: ContentAddress,
*,
backend_id: str | None = None,
) -> VerifyResult:
expected = content_address.to_digest()
primary_name = expected.algorithm
stream = await self._backend.get(content_address)
backend = self._select_backend(backend_id)
stream = await backend.get(content_address)
pair = await digest_stream(stream, primary=primary_name)
head = await self._backend.head(content_address)
head = await backend.head(content_address)
actual_size = head.size_bytes
if pair.primary.hex == expected.hex:
@@ -138,11 +160,26 @@ class InProcessDataPlane:
mismatch=(f"primary digest mismatch: expected {expected.hex}, got {pair.primary.hex}"),
)
async def delete_object(self, content_address: ContentAddress) -> DeletionResult:
return await self._backend.delete(content_address)
async def delete_object(
self,
content_address: ContentAddress,
*,
backend_id: str | None = None,
) -> DeletionResult:
return await self._select_backend(backend_id).delete(content_address)
async def backend_health(self) -> BackendStatus:
return await self._backend.health()
return await self._select_backend(None).health()
async def backend_health_all(self) -> list[BackendStatus]:
return [await backend.health() for backend in self._backends.values()]
def _select_backend(self, backend_id: str | None) -> StorageBackend:
selected = backend_id or self._default_backend_id
try:
return self._backends[selected]
except KeyError as exc:
raise KeyError(f"unknown storage backend: {selected!r}") from exc
async def _file_chunks(path: Path, chunk_size: int) -> AsyncIterator[bytes]:

View File

@@ -84,10 +84,21 @@ class DataPlane(Protocol):
content_address: ContentAddress,
*,
byte_range: tuple[int, int] | None = None,
backend_id: str | None = None,
) -> AsyncIterator[bytes]: ...
async def verify_object(self, content_address: ContentAddress) -> VerifyResult: ...
async def verify_object(
self,
content_address: ContentAddress,
*,
backend_id: str | None = None,
) -> VerifyResult: ...
async def delete_object(self, content_address: ContentAddress) -> DeletionResult: ...
async def delete_object(
self,
content_address: ContentAddress,
*,
backend_id: str | None = None,
) -> DeletionResult: ...
async def backend_health(self) -> BackendStatus: ...

View File

@@ -19,10 +19,11 @@ workplans without changing this module's public surface.
from __future__ import annotations
from datetime import UTC, datetime
from uuid import UUID
import cbor2
from sqlalchemy import delete, insert, update
from sqlalchemy import delete, insert, select, update
from sqlalchemy.ext.asyncio import AsyncConnection
from artifactstore.db.schema import (
@@ -67,7 +68,9 @@ async def _apply_package_created(connection: AsyncConnection, event: Event) -> N
producer=payload["producer"],
subject=payload["subject"],
retention_class=payload["retention_class"],
metadata_schema_id=None,
metadata_schema_id=UUID(payload["metadata_schema_id"])
if payload.get("metadata_schema_id")
else None,
metadata=payload.get("metadata", {}),
status="created",
manifest_digest=None,
@@ -146,8 +149,183 @@ async def _apply_package_finalized(connection: AsyncConnection, event: Event) ->
)
async def _apply_retention_default_applied(
connection: AsyncConnection,
event: Event,
) -> None:
if event.subject_id is None:
raise ValueError("v1.retention.default_applied event must have subject_id")
payload = cbor2.loads(event.payload)
expires_at = _parse_iso(payload["expires_at"])
await connection.execute(
update(artifact_packages)
.where(artifact_packages.c.id == event.subject_id)
.values(
expires_at=expires_at,
last_event_sequence=event.sequence,
)
)
await connection.execute(
update(retention_state)
.where(retention_state.c.package_id == event.subject_id)
.values(
current_expires_at=expires_at,
effective_class=payload["retention_class"],
active_hold_id=None,
eligible_for_deletion=bool(payload.get("eligible_for_deletion", False)),
)
)
async def _apply_retention_extended(connection: AsyncConnection, event: Event) -> None:
if event.subject_id is None:
raise ValueError("v1.retention.extended event must have subject_id")
payload = cbor2.loads(event.payload)
expires_at = _parse_iso(payload["new_expires_at"])
await connection.execute(
update(artifact_packages)
.where(artifact_packages.c.id == event.subject_id)
.values(
expires_at=expires_at,
last_event_sequence=event.sequence,
)
)
await connection.execute(
update(retention_state)
.where(retention_state.c.package_id == event.subject_id)
.values(
current_expires_at=expires_at,
eligible_for_deletion=False,
)
)
async def _apply_retention_hold_applied(connection: AsyncConnection, event: Event) -> None:
if event.subject_id is None:
raise ValueError("v1.retention.hold_applied event must have subject_id")
payload = cbor2.loads(event.payload)
await connection.execute(
update(retention_state)
.where(retention_state.c.package_id == event.subject_id)
.values(
active_hold_id=UUID(payload["hold_id"]),
eligible_for_deletion=False,
)
)
await connection.execute(
update(artifact_packages)
.where(artifact_packages.c.id == event.subject_id)
.values(last_event_sequence=event.sequence)
)
async def _apply_retention_hold_released(connection: AsyncConnection, event: Event) -> None:
if event.subject_id is None:
raise ValueError("v1.retention.hold_released event must have subject_id")
await connection.execute(
update(retention_state)
.where(retention_state.c.package_id == event.subject_id)
.values(active_hold_id=None)
)
await connection.execute(
update(artifact_packages)
.where(artifact_packages.c.id == event.subject_id)
.values(last_event_sequence=event.sequence)
)
async def _apply_retention_deletion_eligible(
connection: AsyncConnection,
event: Event,
) -> None:
if event.subject_id is None:
raise ValueError("v1.retention.deletion_eligible event must have subject_id")
await connection.execute(
update(retention_state)
.where(retention_state.c.package_id == event.subject_id)
.values(eligible_for_deletion=True)
)
await connection.execute(
update(artifact_packages)
.where(artifact_packages.c.id == event.subject_id)
.values(last_event_sequence=event.sequence)
)
async def _apply_storage_location_verified(
connection: AsyncConnection,
event: Event,
) -> None:
payload = cbor2.loads(event.payload)
await connection.execute(
update(storage_locations)
.where(storage_locations.c.id == UUID(payload["storage_location_id"]))
.values(
status="verified" if payload["verified"] else "failed",
last_verified_at=event.created_at,
)
)
async def _apply_storage_location_deleted(
connection: AsyncConnection,
event: Event,
) -> None:
if event.subject_id is None:
raise ValueError("v1.storage.location_deleted event must have subject_id")
payload = cbor2.loads(event.payload)
await connection.execute(
update(storage_locations)
.where(storage_locations.c.id == UUID(payload["storage_location_id"]))
.values(
status="deleted",
restore_status="object_deleted"
if payload.get("object_deleted")
else "reference_released",
)
)
remaining = (
await connection.execute(
select(storage_locations.c.id)
.join(
artifact_files,
artifact_files.c.id == storage_locations.c.artifact_file_id,
)
.where(
artifact_files.c.package_id == event.subject_id,
storage_locations.c.status != "deleted",
)
.limit(1)
)
).first()
package_values: dict[str, object] = {"last_event_sequence": event.sequence}
if remaining is None:
package_values["status"] = "garbage_collected"
await connection.execute(
update(artifact_packages)
.where(artifact_packages.c.id == event.subject_id)
.values(**package_values)
)
def _parse_iso(value: str | None) -> datetime | None:
if value is None:
return None
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
_HANDLERS = {
"v1.package.created": _apply_package_created,
"v1.file.ingested": _apply_file_ingested,
"v1.package.finalized": _apply_package_finalized,
"v1.retention.default_applied": _apply_retention_default_applied,
"v1.retention.extended": _apply_retention_extended,
"v1.retention.hold_applied": _apply_retention_hold_applied,
"v1.retention.hold_released": _apply_retention_hold_released,
"v1.retention.deletion_eligible": _apply_retention_deletion_eligible,
"v1.storage.location_verified": _apply_storage_location_verified,
"v1.storage.location_deleted": _apply_storage_location_deleted,
}

View File

@@ -0,0 +1 @@
"""Pilot producer integrations."""

View File

@@ -0,0 +1,308 @@
"""Guide-board pilot ingestion helper."""
from __future__ import annotations
import json
import mimetypes
import subprocess
from collections.abc import AsyncIterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from artifactstore.registry import Registry
__all__ = ["GUIDE_BOARD_SCHEMA_SLUG", "GuideBoardIngestResult", "ingest_run"]
GUIDE_BOARD_SCHEMA_SLUG = "guide-board.run.v1"
CORE_RUN_PATHS = (
"run.json",
"retention-summary.json",
"plan.json",
"sources.lock.json",
"target-profile.snapshot.json",
"assessment-profile.snapshot.json",
"normalized/evidence.json",
"normalized/findings.json",
"normalized/mappings.json",
"reports/fragments.json",
"reports/submission-package.json",
"exports/export-manifest.json",
)
@dataclass(frozen=True, slots=True)
class GuideBoardIngestResult:
package_id: str
manifest_digest: str
file_count: int
reused_existing: bool = False
async def ingest_run(
run_dir: str | Path,
*,
registry: Registry,
actor: str = "guide-board",
metadata_schema_slug: str = GUIDE_BOARD_SCHEMA_SLUG,
) -> GuideBoardIngestResult:
"""Ingest one guide-board run directory into artifact-store."""
root = Path(run_dir)
run_json = _read_json(root / "run.json")
retention_summary = _read_json(root / "retention-summary.json")
source_lock = _read_json_if_exists(root / "sources.lock.json")
package_manifest_path = root / "reports" / "assessment-package.json"
package_manifest = _read_json(package_manifest_path)
metadata = _metadata(run_json, retention_summary, source_lock)
run_id = str(metadata["run_id"])
existing = await registry.list_packages(
producer="guide-board",
metadata_key="run_id",
metadata_value=run_id,
)
for package in existing:
if package.status == "finalized" and package.manifest_digest_hex:
return GuideBoardIngestResult(
package_id=str(package.id),
manifest_digest=f"blake3:{package.manifest_digest_hex}",
file_count=0,
reused_existing=True,
)
package_id = await registry.create_package(
name=f"guide-board run {run_id}",
producer="guide-board",
subject=str(metadata["target_profile_ref"]),
retention_class=str(retention_summary.get("retention_class", "release-evidence")),
actor=actor,
metadata=metadata,
metadata_schema_slug=metadata_schema_slug,
)
paths = _declared_paths(package_manifest)
paths.update(_retained_report_paths(retention_summary))
paths.add("reports/assessment-package.json")
for rel_path in CORE_RUN_PATHS:
if (root / rel_path).is_file():
paths.add(rel_path)
for rel_path in sorted(paths):
source = root / rel_path
await registry.ingest_file(
package_id,
relative_path=rel_path,
media_type=mimetypes.guess_type(source.name)[0] or "application/octet-stream",
stream=_file_chunks(source),
actor=actor,
)
await registry.finalize_package(package_id, actor=actor)
package = await registry.get_package(package_id)
if package.manifest_digest_hex is None:
raise RuntimeError(f"package {package_id} finalized without manifest digest")
return GuideBoardIngestResult(
package_id=str(package_id),
manifest_digest=f"blake3:{package.manifest_digest_hex}",
file_count=len(paths),
)
def _metadata(
run_json: dict[str, Any],
retention_summary: dict[str, Any],
source_lock: dict[str, Any] | None,
) -> dict[str, Any]:
summary = retention_summary.get("summary", {})
if not isinstance(summary, dict):
summary = {}
return {
"run_id": str(run_json.get("run_id") or run_json.get("id") or retention_summary["run_id"]),
"target_profile_ref": str(run_json["target_profile_ref"]),
"assessment_profile_ref": str(run_json["assessment_profile_ref"]),
"result_status": str(
run_json.get("result_status") or run_json.get("status") or summary.get("status")
),
"source_commits": _source_commits(run_json, source_lock),
"report_paths": sorted(_retained_report_paths(retention_summary)),
"evidence_counts": _evidence_counts(retention_summary, summary),
"finding_counts": _finding_counts(retention_summary, summary),
}
def _declared_paths(package_manifest: dict[str, Any]) -> set[str]:
paths: set[str] = set()
raw_files = package_manifest.get("files", [])
if raw_files is not None and not isinstance(raw_files, list):
raise ValueError("assessment-package.json 'files' must be a list")
for entry in raw_files or []:
if isinstance(entry, str):
paths.add(entry)
elif isinstance(entry, dict) and isinstance(entry.get("path"), str):
paths.add(entry["path"])
else:
raise ValueError(f"invalid assessment package file entry: {entry!r}")
raw_artifacts = package_manifest.get("artifact_manifest", [])
if raw_artifacts is not None and not isinstance(raw_artifacts, list):
raise ValueError("assessment-package.json 'artifact_manifest' must be a list")
for entry in raw_artifacts or []:
if isinstance(entry, dict) and isinstance(entry.get("path"), str):
paths.add(entry["path"])
else:
raise ValueError(f"invalid assessment package artifact entry: {entry!r}")
return paths
def _retained_report_paths(retention_summary: dict[str, Any]) -> set[str]:
paths: set[str] = set()
for key in ("report_paths", "report_refs", "export_refs"):
raw_paths = retention_summary.get(key, [])
if not isinstance(raw_paths, list):
continue
paths.update(path for path in raw_paths if isinstance(path, str) and path)
return paths
def _source_commits(
run_json: dict[str, Any],
source_lock: dict[str, Any] | None,
) -> dict[str, str]:
raw = run_json.get("source_commits")
if isinstance(raw, dict):
return {str(key): str(value) for key, value in raw.items()}
commits: dict[str, str] = {}
if source_lock is not None:
for label, path in _source_paths(source_lock).items():
commit = _git_head(path)
if commit is not None:
commits[label] = commit
if commits:
return commits
fingerprints = _source_fingerprints(source_lock)
if fingerprints:
return fingerprints
return {"unknown": "unrecorded-source"}
def _source_paths(source_lock: dict[str, Any]) -> dict[str, Path]:
paths: dict[str, Path] = {}
profiles = source_lock.get("profiles", {})
if isinstance(profiles, dict):
for key, value in profiles.items():
if isinstance(value, dict) and isinstance(value.get("path"), str):
paths[f"profile:{key}"] = Path(value["path"])
extensions = source_lock.get("extensions", [])
if isinstance(extensions, list):
for entry in extensions:
if not isinstance(entry, dict):
continue
extension_id = str(entry.get("id") or "unknown-extension")
raw_path = entry.get("path")
if isinstance(raw_path, str) and Path(raw_path).is_absolute():
paths[f"extension:{extension_id}"] = Path(raw_path)
return paths
def _git_head(path: Path) -> str | None:
try:
completed = subprocess.run(
["git", "-C", str(path.parent if path.is_file() else path), "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
timeout=5,
)
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
return None
commit = completed.stdout.strip()
return commit or None
def _source_fingerprints(source_lock: dict[str, Any]) -> dict[str, str]:
fingerprints: dict[str, str] = {}
for key, value in source_lock.items():
if key == "id" and isinstance(value, str):
fingerprints["source_lock"] = value
profiles = source_lock.get("profiles", {})
if isinstance(profiles, dict):
for key, value in profiles.items():
if isinstance(value, dict) and isinstance(value.get("checksum"), str):
fingerprints[f"profile:{key}"] = value["checksum"]
extensions = source_lock.get("extensions", [])
if isinstance(extensions, list):
for entry in extensions:
if isinstance(entry, dict) and isinstance(entry.get("manifest_checksum"), str):
fingerprints[f"extension:{entry.get('id', 'unknown-extension')}"] = entry[
"manifest_checksum"
]
return fingerprints
def _evidence_counts(
retention_summary: dict[str, Any],
summary: dict[str, Any],
) -> dict[str, int]:
raw = retention_summary.get("evidence_counts")
if isinstance(raw, dict):
return _int_mapping(raw)
raw_evidence = summary.get("evidence_results")
if isinstance(raw_evidence, dict):
return _int_mapping(raw_evidence)
return {}
def _finding_counts(
retention_summary: dict[str, Any],
summary: dict[str, Any],
) -> dict[str, int]:
raw = retention_summary.get("finding_counts")
if isinstance(raw, dict):
return _int_mapping(raw)
keys = (
"finding_count",
"unexpected_findings",
"expected_findings",
"waived_findings",
"challenged_findings",
"authority_exclusions",
"unresolved_defects",
"unresolved_review_items",
)
return _int_mapping({key: summary[key] for key in keys if key in summary})
def _int_mapping(raw: dict[str, Any]) -> dict[str, int]:
return {
str(key): int(value)
for key, value in raw.items()
if isinstance(value, int) and not isinstance(value, bool)
}
def _read_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as fh:
payload = json.load(fh)
if not isinstance(payload, dict):
raise ValueError(f"{path} must contain a JSON object")
return payload
def _read_json_if_exists(path: Path) -> dict[str, Any] | None:
if not path.exists():
return None
return _read_json(path)
async def _file_chunks(path: Path, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]:
with path.open("rb") as fh:
while True:
chunk = fh.read(chunk_size)
if not chunk:
break
yield chunk

View File

@@ -19,19 +19,21 @@ source of truth (ADR-0002).
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator, Sequence
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from typing import Any, cast
from uuid import UUID
import cbor2
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncEngine
from artifactstore.dataplane.spi import DataPlane
from artifactstore.dataplane.spi import DataPlane, IngestHints
from artifactstore.db.schema import (
artifact_files,
artifact_packages,
metadata_schemas,
retention_classes,
retention_state,
storage_locations,
@@ -39,7 +41,7 @@ from artifactstore.db.schema import (
from artifactstore.db.schema import (
events as events_t,
)
from artifactstore.events import RegistryViewWriter, make_event, tail, write
from artifactstore.events import RegistryViewWriter, fetch_since, make_event, tail, write
from artifactstore.events.model import Event
from artifactstore.identity import ContentAddress
from artifactstore.manifest import (
@@ -61,9 +63,24 @@ from artifactstore.manifest import (
)
from artifactstore.manifest.codec import decode as manifest_decode
from artifactstore.manifest.projection import jcs_projection
from artifactstore.retention import RetentionPolicy
from artifactstore.storage.spi import BackendStatus
__all__ = ["Registry"]
__all__ = [
"DuplicateRelativePathError",
"FileNotFoundError",
"FileRecord",
"GarbageCollectionRecord",
"IllegalPackageStateError",
"MetadataSchemaRecord",
"PackageNotFoundError",
"PackageRecord",
"Registry",
"RetentionClassRecord",
"RetentionStateError",
"RetentionStateRecord",
"StorageVerificationRecord",
]
class PackageNotFoundError(KeyError):
@@ -82,6 +99,115 @@ class DuplicateRelativePathError(ValueError):
"""Raised when ingest_file would create a duplicate ``relative_path`` in a package."""
class RetentionStateError(ValueError):
"""Raised when a retention lifecycle operation is invalid."""
@dataclass(frozen=True, slots=True)
class MetadataSchemaRecord:
"""Registered package metadata schema."""
id: UUID
slug: str
json_schema: dict[str, Any]
created_at: datetime | None
@dataclass(frozen=True, slots=True)
class PackageRecord:
"""Materialised package row projected into the registry API."""
id: UUID
name: str
producer: str
subject: str
retention_class: str
metadata: dict[str, Any]
status: str
manifest_digest_hex: str | None
created_at: datetime | None
finalized_at: datetime | None
expires_at: datetime | None
last_event_sequence: int
metadata_schema_id: UUID | None = None
@dataclass(frozen=True, slots=True)
class FileRecord:
"""Materialised file row plus its primary storage location."""
id: UUID
package_id: UUID
relative_path: str
media_type: str
size_bytes: int
digest_algorithm: str
digest_primary_hex: str
digest_sha256_hex: str
created_at: datetime | None
backend_id: str
content_address: str
object_key: str
retrieval_tier: str
storage_status: str
@dataclass(frozen=True, slots=True)
class RetentionClassRecord:
"""Configured retention-class row."""
class_id: str
default_duration_seconds: int | None
deletion_strategy: str
@dataclass(frozen=True, slots=True)
class RetentionStateRecord:
"""Materialised retention state for one package."""
package_id: UUID
current_expires_at: datetime | None
effective_class: str
active_hold_id: UUID | None
eligible_for_deletion: bool
@dataclass(frozen=True, slots=True)
class StorageVerificationRecord:
"""Result of verifying one storage location."""
storage_location_id: UUID
file_id: UUID
backend_id: str
content_address: str
verified: bool
mismatch: str | None
@dataclass(frozen=True, slots=True)
class GarbageCollectionRecord:
"""Result of releasing one storage location during garbage collection."""
storage_location_id: UUID
file_id: UUID
package_id: UUID
backend_id: str
content_address: str
object_delete_attempted: bool
object_deleted: bool
ref_count_before: int
ref_count_after: int
_RETENTION_EVENT_TYPES = (
"v1.retention.default_applied",
"v1.retention.extended",
"v1.retention.hold_applied",
"v1.retention.hold_released",
"v1.retention.deletion_eligible",
)
class Registry:
"""Library-shaped orchestrator over events, dataplane, and views."""
@@ -90,10 +216,14 @@ class Registry:
engine: AsyncEngine,
dataplane: DataPlane,
view_writer: RegistryViewWriter | None = None,
retention_policy: RetentionPolicy | None = None,
backend_selector: Callable[[str, str], str | None] | None = None,
) -> None:
self._engine = engine
self._dataplane = dataplane
self._view_writer = view_writer or RegistryViewWriter()
self._retention_policy = retention_policy or RetentionPolicy()
self._backend_selector = backend_selector
# ---- mutating operations ------------------------------------------------
@@ -106,9 +236,15 @@ class Registry:
retention_class: str,
actor: str,
metadata: dict[str, Any] | None = None,
metadata_schema_slug: str | None = None,
) -> UUID:
"""Create a new package; returns its ``UUID``."""
await self._validate_retention_class(retention_class)
retention_class_row = await self._get_retention_class(retention_class)
package_metadata = metadata or {}
metadata_schema_id = await self._validate_metadata_schema(
metadata_schema_slug,
package_metadata,
)
package_id = uuid.uuid4()
payload = cbor2.dumps(
{
@@ -116,7 +252,8 @@ class Registry:
"producer": producer,
"subject": subject,
"retention_class": retention_class,
"metadata": metadata or {},
"metadata": package_metadata,
"metadata_schema_id": str(metadata_schema_id) if metadata_schema_id else None,
},
canonical=True,
)
@@ -130,6 +267,31 @@ class Registry:
async with self._engine.begin() as conn:
written = await write(conn, event)
await self._view_writer.apply(conn, written)
if written.created_at is None:
raise RuntimeError("created package event missing created_at")
decision = self._retention_policy.default_for(
retention_class=retention_class,
class_default_seconds=retention_class_row.default_duration_seconds,
base_time=_ensure_aware(written.created_at),
)
default_payload = cbor2.dumps(
{
"retention_class": decision.retention_class,
"default_duration_seconds": decision.default_duration_seconds,
"expires_at": _iso(decision.expires_at),
"eligible_for_deletion": decision.eligible_for_deletion,
},
canonical=True,
)
default_event = make_event(
event_type="v1.retention.default_applied",
subject_kind="retention",
subject_id=package_id,
actor=actor,
payload=default_payload,
)
written_default = await write(conn, default_event)
await self._view_writer.apply(conn, written_default)
return package_id
async def ingest_file(
@@ -167,7 +329,15 @@ class Registry:
f"relative_path {relative_path!r} already exists in package {package_id}"
)
ingest = await self._dataplane.ingest_stream(stream)
selected_backend = (
self._backend_selector(pkg_row.producer, pkg_row.retention_class)
if self._backend_selector is not None
else None
)
ingest = await self._dataplane.ingest_stream(
stream,
hints=IngestHints(backend_id=selected_backend),
)
file_id = uuid.uuid4()
storage_location_id = uuid.uuid4()
@@ -265,6 +435,533 @@ class Registry:
# ---- read-only operations ----------------------------------------------
async def list_packages(
self,
*,
producer: str | None = None,
subject: str | None = None,
retention_class: str | None = None,
metadata_key: str | None = None,
metadata_value: str | None = None,
) -> list[PackageRecord]:
"""List package materialised views, optionally filtered for producer
workflows. Metadata key filtering is performed after the portable SQL
filters so the same behavior works on SQLite and PostgreSQL."""
stmt = select(artifact_packages).order_by(
artifact_packages.c.created_at,
artifact_packages.c.id,
)
if producer is not None:
stmt = stmt.where(artifact_packages.c.producer == producer)
if subject is not None:
stmt = stmt.where(artifact_packages.c.subject == subject)
if retention_class is not None:
stmt = stmt.where(artifact_packages.c.retention_class == retention_class)
async with self._engine.connect() as conn:
rows = (await conn.execute(stmt)).all()
records = [_package_record_from_row(r) for r in rows]
if metadata_key is None:
return records
filtered: list[PackageRecord] = []
for record in records:
if metadata_key not in record.metadata:
continue
if metadata_value is not None and str(record.metadata[metadata_key]) != metadata_value:
continue
filtered.append(record)
return filtered
async def get_package(self, package_id: UUID) -> PackageRecord:
"""Return one package materialised view."""
async with self._engine.connect() as conn:
row = (
await conn.execute(
select(artifact_packages).where(artifact_packages.c.id == package_id)
)
).first()
if row is None:
raise PackageNotFoundError(f"package not found: {package_id}")
return _package_record_from_row(row)
async def get_file_metadata(self, file_id: UUID) -> FileRecord:
"""Return file metadata plus the primary storage location."""
stmt = (
select(
artifact_files.c.id.label("file_id"),
artifact_files.c.package_id,
artifact_files.c.relative_path,
artifact_files.c.media_type,
artifact_files.c.size_bytes,
artifact_files.c.digest_algorithm,
artifact_files.c.digest_primary,
artifact_files.c.digest_sha256,
artifact_files.c.created_at,
storage_locations.c.backend_id,
storage_locations.c.content_address,
storage_locations.c.object_key,
storage_locations.c.retrieval_tier,
storage_locations.c.status.label("storage_status"),
)
.join(
storage_locations,
storage_locations.c.artifact_file_id == artifact_files.c.id,
)
.where(artifact_files.c.id == file_id)
.limit(1)
)
async with self._engine.connect() as conn:
row = (await conn.execute(stmt)).first()
if row is None:
raise FileNotFoundError(f"file not found: {file_id}")
return FileRecord(
id=row.file_id,
package_id=row.package_id,
relative_path=row.relative_path,
media_type=row.media_type,
size_bytes=row.size_bytes,
digest_algorithm=row.digest_algorithm,
digest_primary_hex=row.digest_primary.hex(),
digest_sha256_hex=row.digest_sha256.hex(),
created_at=row.created_at,
backend_id=row.backend_id,
content_address=row.content_address,
object_key=row.object_key,
retrieval_tier=row.retrieval_tier,
storage_status=row.storage_status,
)
async def list_retention_classes(self) -> list[RetentionClassRecord]:
"""Return configured retention classes."""
async with self._engine.connect() as conn:
rows = (
await conn.execute(
select(retention_classes).order_by(retention_classes.c.class_id)
)
).all()
return [
RetentionClassRecord(
class_id=r.class_id,
default_duration_seconds=r.default_duration_seconds,
deletion_strategy=r.deletion_strategy,
)
for r in rows
]
async def register_metadata_schema(
self,
*,
slug: str,
json_schema: dict[str, Any],
) -> UUID:
"""Register a package metadata JSON Schema, idempotent by slug."""
schema_id = uuid.uuid4()
async with self._engine.begin() as conn:
existing = (
await conn.execute(
select(metadata_schemas.c.id).where(metadata_schemas.c.slug == slug)
)
).first()
if existing is not None:
return UUID(str(existing.id))
await conn.execute(
metadata_schemas.insert().values(
id=schema_id,
slug=slug,
json_schema=json_schema,
)
)
return schema_id
async def get_metadata_schema(self, slug: str) -> MetadataSchemaRecord:
"""Return one registered metadata schema by slug."""
async with self._engine.connect() as conn:
row = (
await conn.execute(
select(metadata_schemas).where(metadata_schemas.c.slug == slug)
)
).first()
if row is None:
raise KeyError(f"metadata schema not found: {slug}")
return MetadataSchemaRecord(
id=row.id,
slug=row.slug,
json_schema=dict(row.json_schema),
created_at=row.created_at,
)
async def get_retention_state(self, package_id: UUID) -> RetentionStateRecord:
"""Return the retention materialised view for one package."""
async with self._engine.connect() as conn:
row = (
await conn.execute(
select(retention_state).where(retention_state.c.package_id == package_id)
)
).first()
if row is None:
raise PackageNotFoundError(f"package not found: {package_id}")
return _retention_state_record_from_row(row)
async def extend_retention(
self,
package_id: UUID,
*,
new_expires_at: datetime,
reason: str,
actor: str,
) -> RetentionStateRecord:
"""Extend package retention to a strictly later expiry."""
clean_reason = _require_reason(reason)
target_expires_at = _ensure_aware(new_expires_at)
current = await self.get_retention_state(package_id)
if current.current_expires_at is None:
raise RetentionStateError(
f"package {package_id} has no current expiry to extend"
)
current_expires_at = _ensure_aware(current.current_expires_at)
if target_expires_at <= current_expires_at:
raise RetentionStateError("new_expires_at must be strictly later than current expiry")
payload = cbor2.dumps(
{
"previous_expires_at": _iso(current_expires_at),
"new_expires_at": _iso(target_expires_at),
"reason": clean_reason,
},
canonical=True,
)
event = make_event(
event_type="v1.retention.extended",
subject_kind="retention",
subject_id=package_id,
actor=actor,
payload=payload,
)
async with self._engine.begin() as conn:
written = await write(conn, event)
await self._view_writer.apply(conn, written)
return await self.get_retention_state(package_id)
async def apply_retention_hold(
self,
package_id: UUID,
*,
reason: str,
actor: str,
) -> UUID:
"""Apply one active hold to a package and return the hold id."""
clean_reason = _require_reason(reason)
current = await self.get_retention_state(package_id)
if current.active_hold_id is not None:
raise RetentionStateError(
f"package {package_id} already has active hold {current.active_hold_id}"
)
hold_id = uuid.uuid4()
payload = cbor2.dumps(
{
"hold_id": str(hold_id),
"reason": clean_reason,
},
canonical=True,
)
event = make_event(
event_type="v1.retention.hold_applied",
subject_kind="retention",
subject_id=package_id,
actor=actor,
payload=payload,
)
async with self._engine.begin() as conn:
written = await write(conn, event)
await self._view_writer.apply(conn, written)
return hold_id
async def release_retention_hold(
self,
package_id: UUID,
hold_id: UUID,
*,
reason: str,
actor: str,
now: datetime | None = None,
) -> RetentionStateRecord:
"""Release the active hold, emitting eligibility if the package is expired."""
clean_reason = _require_reason(reason)
current = await self.get_retention_state(package_id)
if current.active_hold_id != hold_id:
raise RetentionStateError(f"hold {hold_id} is not active on package {package_id}")
payload = cbor2.dumps(
{
"hold_id": str(hold_id),
"reason": clean_reason,
},
canonical=True,
)
release_event = make_event(
event_type="v1.retention.hold_released",
subject_kind="retention",
subject_id=package_id,
actor=actor,
payload=payload,
)
effective_now = _ensure_aware(now or datetime.now(UTC))
expired_after_release = (
current.current_expires_at is not None
and _ensure_aware(current.current_expires_at) <= effective_now
and not current.eligible_for_deletion
)
async with self._engine.begin() as conn:
written_release = await write(conn, release_event)
await self._view_writer.apply(conn, written_release)
if expired_after_release:
eligible_event = make_event(
event_type="v1.retention.deletion_eligible",
subject_kind="retention",
subject_id=package_id,
actor=actor,
payload=_deletion_eligible_payload(
expires_at=current.current_expires_at,
reason="hold released after expiry",
),
)
written_eligible = await write(conn, eligible_event)
await self._view_writer.apply(conn, written_eligible)
return await self.get_retention_state(package_id)
async def sweep_deletion_eligibility(
self,
*,
now: datetime | None = None,
actor: str = "retention-sweeper",
) -> list[UUID]:
"""Mark expired, unheld packages as eligible for deletion."""
effective_now = _ensure_aware(now or datetime.now(UTC))
marked: list[UUID] = []
async with self._engine.begin() as conn:
rows = (await conn.execute(select(retention_state))).all()
for row in rows:
record = _retention_state_record_from_row(row)
if record.eligible_for_deletion or record.active_hold_id is not None:
continue
if record.current_expires_at is None:
continue
if _ensure_aware(record.current_expires_at) > effective_now:
continue
event = make_event(
event_type="v1.retention.deletion_eligible",
subject_kind="retention",
subject_id=record.package_id,
actor=actor,
payload=_deletion_eligible_payload(
expires_at=record.current_expires_at,
reason="retention expiry reached",
),
)
written = await write(conn, event)
await self._view_writer.apply(conn, written)
marked.append(record.package_id)
return marked
async def retention_history(self, package_id: UUID) -> list[Event]:
"""Return all retention events for a package, ordered by sequence."""
await self.get_package(package_id)
async with self._engine.connect() as conn:
rows = (
await conn.execute(
select(events_t)
.where(
events_t.c.subject_id == package_id,
events_t.c.event_type.in_(_RETENTION_EVENT_TYPES),
)
.order_by(events_t.c.sequence)
)
).all()
return [
Event(
event_type=r.event_type,
subject_kind=r.subject_kind,
subject_id=r.subject_id,
actor=r.actor,
payload=r.payload,
payload_digest=r.payload_digest,
sequence=r.sequence,
created_at=r.created_at,
)
for r in rows
]
async def verify_storage_locations(
self,
*,
backend_id: str | None = None,
actor: str = "storage-verifier",
) -> list[StorageVerificationRecord]:
"""Re-read storage locations and emit verification events."""
stmt = select(storage_locations)
if backend_id is not None:
stmt = stmt.where(storage_locations.c.backend_id == backend_id)
stmt = stmt.where(storage_locations.c.status != "deleted")
async with self._engine.connect() as conn:
rows = (await conn.execute(stmt.order_by(storage_locations.c.id))).all()
results: list[StorageVerificationRecord] = []
for row in rows:
ca = ContentAddress(row.content_address)
verified = False
mismatch: str | None = None
actual_size_bytes: int | None = None
actual_primary_hex: str | None = None
actual_sha256_hex: str | None = None
try:
result = await self._dataplane.verify_object(ca, backend_id=row.backend_id)
verified = result.verified
mismatch = result.mismatch
actual_size_bytes = result.actual_size_bytes
actual_primary_hex = result.actual_primary_digest.hex
actual_sha256_hex = result.actual_sha256_digest.hex
except Exception as exc:
mismatch = f"{type(exc).__name__}: {exc}"
payload = cbor2.dumps(
{
"storage_location_id": str(row.id),
"file_id": str(row.artifact_file_id),
"backend_id": row.backend_id,
"content_address": row.content_address,
"verified": verified,
"mismatch": mismatch,
"actual_size_bytes": actual_size_bytes,
"actual_primary_hex": actual_primary_hex,
"actual_sha256_hex": actual_sha256_hex,
},
canonical=True,
)
event = make_event(
event_type="v1.storage.location_verified",
subject_kind="storage",
subject_id=row.artifact_file_id,
actor=actor,
payload=payload,
)
async with self._engine.begin() as conn:
written = await write(conn, event)
await self._view_writer.apply(conn, written)
results.append(
StorageVerificationRecord(
storage_location_id=row.id,
file_id=row.artifact_file_id,
backend_id=row.backend_id,
content_address=row.content_address,
verified=verified,
mismatch=mismatch,
)
)
return results
async def collect_garbage(
self,
*,
actor: str = "garbage-collector",
) -> list[GarbageCollectionRecord]:
"""Release deletion-eligible storage locations and delete unreferenced bytes."""
stmt = (
select(
artifact_files.c.package_id,
artifact_files.c.id.label("file_id"),
storage_locations.c.id.label("storage_location_id"),
storage_locations.c.backend_id,
storage_locations.c.content_address,
)
.join(
artifact_files,
artifact_files.c.id == storage_locations.c.artifact_file_id,
)
.join(
retention_state,
retention_state.c.package_id == artifact_files.c.package_id,
)
.where(
retention_state.c.eligible_for_deletion.is_(True),
retention_state.c.active_hold_id.is_(None),
storage_locations.c.status != "deleted",
)
.order_by(
artifact_files.c.package_id,
artifact_files.c.relative_path,
storage_locations.c.id,
)
)
async with self._engine.connect() as conn:
rows = (await conn.execute(stmt)).all()
groups: dict[tuple[str, str], list[Any]] = {}
for row in rows:
groups.setdefault((row.backend_id, row.content_address), []).append(row)
results: list[GarbageCollectionRecord] = []
for (backend_id, content_address), group_rows in groups.items():
async with self._engine.connect() as conn:
active_refs = (
await conn.execute(
select(storage_locations.c.id).where(
storage_locations.c.backend_id == backend_id,
storage_locations.c.content_address == content_address,
storage_locations.c.status != "deleted",
)
)
).all()
ref_count_before = len(active_refs)
ref_count_after = max(ref_count_before - len(group_rows), 0)
object_delete_attempted = ref_count_after == 0
object_deleted = False
if object_delete_attempted:
deletion = await self._dataplane.delete_object(
ContentAddress(content_address),
backend_id=backend_id,
)
object_deleted = deletion.deleted
for row in group_rows:
payload = cbor2.dumps(
{
"storage_location_id": str(row.storage_location_id),
"file_id": str(row.file_id),
"package_id": str(row.package_id),
"backend_id": backend_id,
"content_address": content_address,
"object_delete_attempted": object_delete_attempted,
"object_deleted": object_deleted,
"ref_count_before": ref_count_before,
"ref_count_after": ref_count_after,
},
canonical=True,
)
event = make_event(
event_type="v1.storage.location_deleted",
subject_kind="package",
subject_id=row.package_id,
actor=actor,
payload=payload,
)
async with self._engine.begin() as conn:
written = await write(conn, event)
await self._view_writer.apply(conn, written)
results.append(
GarbageCollectionRecord(
storage_location_id=row.storage_location_id,
file_id=row.file_id,
package_id=row.package_id,
backend_id=backend_id,
content_address=content_address,
object_delete_attempted=object_delete_attempted,
object_deleted=object_deleted,
ref_count_before=ref_count_before,
ref_count_after=ref_count_after,
)
)
return results
async def get_manifest_bytes(self, package_id: UUID, *, format: str = "cbor") -> bytes:
"""Return the finalised manifest. ``format`` is ``cbor`` (canonical
CBOR, the wire form) or ``json`` (the JCS projection)."""
@@ -289,20 +986,33 @@ class Registry:
return jcs_projection(manifest_decode(payload_bytes))
raise ValueError(f"unknown manifest format: {format!r} (expected 'cbor' or 'json')")
async def get_file(self, file_id: UUID) -> AsyncIterator[bytes]:
async def get_file(
self,
file_id: UUID,
*,
byte_range: tuple[int, int] | None = None,
) -> AsyncIterator[bytes]:
"""Return an async byte iterator for the bytes of a stored file."""
record = await self.get_file_metadata(file_id)
if record.storage_status == "deleted":
raise FileNotFoundError(f"file has been garbage collected: {file_id}")
ca = ContentAddress(record.content_address)
return await self._dataplane.serve_object(
ca,
byte_range=byte_range,
backend_id=record.backend_id,
)
async def fetch_events(
self,
*,
since_sequence: int = 0,
limit: int = 100,
) -> list[Event]:
"""Fetch one ordered batch of events with sequence greater than
``since_sequence``."""
async with self._engine.connect() as conn:
row = (
await conn.execute(
select(storage_locations)
.where(storage_locations.c.artifact_file_id == file_id)
.limit(1)
)
).first()
if row is None:
raise FileNotFoundError(f"file not found: {file_id}")
ca = ContentAddress(row.content_address)
return await self._dataplane.serve_object(ca)
return await fetch_since(conn, since_sequence=since_sequence, limit=limit)
def tail_events(
self,
@@ -336,31 +1046,126 @@ class Registry:
"""Probe the configured storage backend through the data plane."""
return await self._dataplane.backend_health()
async def backend_health_all(self) -> list[BackendStatus]:
"""Probe every configured storage backend when the data plane supports it."""
probe_all = getattr(self._dataplane, "backend_health_all", None)
if probe_all is None:
return [await self.backend_health()]
typed_probe = cast(Callable[[], Awaitable[list[BackendStatus]]], probe_all)
return await typed_probe()
async def failed_storage_locations_count(self) -> int:
"""Count storage locations currently marked failed."""
async with self._engine.connect() as conn:
rows = (
await conn.execute(
select(storage_locations.c.id).where(storage_locations.c.status == "failed")
)
).all()
return len(rows)
async def dispose(self) -> None:
"""Release the engine's connection pool. Idempotent."""
await self._engine.dispose()
# ---- internals ----------------------------------------------------------
async def _validate_retention_class(self, retention_class: str) -> None:
async def _get_retention_class(self, retention_class: str) -> RetentionClassRecord:
async with self._engine.connect() as conn:
row = (
await conn.execute(
select(retention_classes.c.class_id).where(
retention_classes.c.class_id == retention_class
)
select(retention_classes).where(retention_classes.c.class_id == retention_class)
)
).first()
if row is None:
raise ValueError(f"unknown retention class: {retention_class!r}")
return RetentionClassRecord(
class_id=row.class_id,
default_duration_seconds=row.default_duration_seconds,
deletion_strategy=row.deletion_strategy,
)
async def _validate_metadata_schema(
self,
slug: str | None,
metadata: dict[str, Any],
) -> UUID | None:
if slug is None:
return None
try:
schema = await self.get_metadata_schema(slug)
except KeyError as exc:
raise ValueError(str(exc)) from exc
required = schema.json_schema.get("required", [])
if not isinstance(required, list):
raise ValueError(f"metadata schema {slug!r} has invalid required list")
missing = [key for key in required if isinstance(key, str) and key not in metadata]
if missing:
raise ValueError(f"metadata missing required schema keys: {', '.join(missing)}")
return schema.id
def _iso(value: datetime | None) -> str | None:
if value is None:
return None
return _ensure_aware(value).isoformat()
def _ensure_aware(value: datetime) -> datetime:
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.isoformat()
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def _parse_iso_datetime(value: str | None) -> datetime | None:
if value is None:
return None
return _ensure_aware(datetime.fromisoformat(value))
def _require_reason(reason: str) -> str:
clean = reason.strip()
if not clean:
raise RetentionStateError("reason is required")
return clean
def _deletion_eligible_payload(*, expires_at: datetime | None, reason: str) -> bytes:
return cbor2.dumps(
{
"expires_at": _iso(expires_at),
"reason": reason,
},
canonical=True,
)
def _package_record_from_row(row: Any) -> PackageRecord:
return PackageRecord(
id=row.id,
name=row.name,
producer=row.producer,
subject=row.subject,
retention_class=row.retention_class,
metadata=dict(row.metadata) if row.metadata else {},
status=row.status,
manifest_digest_hex=row.manifest_digest.hex() if row.manifest_digest else None,
created_at=row.created_at,
finalized_at=row.finalized_at,
expires_at=row.expires_at,
last_event_sequence=row.last_event_sequence,
metadata_schema_id=row.metadata_schema_id,
)
def _retention_state_record_from_row(row: Any) -> RetentionStateRecord:
return RetentionStateRecord(
package_id=row.package_id,
current_expires_at=row.current_expires_at,
effective_class=row.effective_class,
active_hold_id=row.active_hold_id,
eligible_for_deletion=bool(row.eligible_for_deletion),
)
def _build_manifest(

View File

@@ -1,5 +1,85 @@
"""Retention policy engine.
"""Retention policy engine."""
Seed classes land in ARTIFACT-STORE-WP-0001-T002 (data model). Active policy
operations (extensions, holds, sweeper) land in workplan WP-0003.
"""
from __future__ import annotations
import tomllib
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
__all__ = ["RetentionDecision", "RetentionPolicy"]
@dataclass(frozen=True, slots=True)
class RetentionDecision:
"""Computed default retention outcome for a package."""
retention_class: str
default_duration_seconds: int | None
expires_at: datetime | None
eligible_for_deletion: bool = False
class RetentionPolicy:
"""Applies operator-configured default retention durations.
The database stores seed defaults for each retention class. Operators may
override those defaults through a TOML file whose shape is documented in
``docs/OPERATOR.md``.
"""
def __init__(self, overrides: Mapping[str, int | None] | None = None) -> None:
self._overrides = dict(overrides or {})
@classmethod
def from_toml(cls, path: str | Path | None) -> RetentionPolicy:
if path is None or str(path).strip() == "":
return cls()
config_path = Path(path)
with config_path.open("rb") as fh:
raw = tomllib.load(fh)
return cls(_parse_duration_overrides(raw))
def default_for(
self,
*,
retention_class: str,
class_default_seconds: int | None,
base_time: datetime,
) -> RetentionDecision:
duration_seconds = self._overrides.get(retention_class, class_default_seconds)
expires_at = (
None
if duration_seconds is None
else base_time + timedelta(seconds=duration_seconds)
)
return RetentionDecision(
retention_class=retention_class,
default_duration_seconds=duration_seconds,
expires_at=expires_at,
eligible_for_deletion=False,
)
def _parse_duration_overrides(raw: Mapping[str, Any]) -> dict[str, int | None]:
section = raw.get("retention_classes", {})
if not isinstance(section, dict):
raise ValueError("retention_classes must be a TOML table")
overrides: dict[str, int | None] = {}
for class_id, value in section.items():
duration = value.get("default_duration_seconds") if isinstance(value, dict) else value
if duration is None:
overrides[class_id] = None
continue
if not isinstance(duration, int):
raise ValueError(
f"retention_classes.{class_id}.default_duration_seconds must be an integer"
)
if duration < 0:
raise ValueError(
f"retention_classes.{class_id}.default_duration_seconds must be non-negative"
)
overrides[class_id] = duration
return overrides

View File

@@ -6,6 +6,7 @@ adding adapters never touches the registry or API layers.
"""
from artifactstore.storage.backends.local import LocalBackend
from artifactstore.storage.backends.s3 import S3Backend, S3BackendConfig
from artifactstore.storage.registry import (
clear as clear_backends,
)
@@ -32,6 +33,8 @@ __all__ = [
"DeletionResult",
"LocalBackend",
"ObjectNotFoundError",
"S3Backend",
"S3BackendConfig",
"StorageBackend",
"StorageObjectMetadata",
"StorageReceipt",

View File

@@ -0,0 +1,276 @@
"""S3-compatible storage backend (Ceph RGW / MinIO / AWS S3)."""
from __future__ import annotations
from collections.abc import AsyncIterator, Callable
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from typing import Any, cast
from artifactstore.identity import ContentAddress
from artifactstore.storage.spi import (
BackendStatus,
DeletionResult,
ObjectNotFoundError,
StorageObjectMetadata,
StorageReceipt,
)
__all__ = ["S3Backend", "S3BackendConfig"]
_DEFAULT_CHUNK_SIZE = 64 * 1024
@dataclass(frozen=True, slots=True)
class S3BackendConfig:
endpoint_url: str
region: str
bucket: str
key_prefix: str = ""
access_key_id: str | None = None
secret_access_key: str | None = None
session_token: str | None = None
storage_class: str | None = None
sse: str | None = None
multipart_threshold_bytes: int = 64 * 1024 * 1024
multipart_chunk_bytes: int = 8 * 1024 * 1024
ClientFactory = Callable[[], AbstractAsyncContextManager[Any]]
# Returns (access_key_id, secret_access_key, session_token) for each new
# client, so STS-vended credentials rotated by a sidecar/controller (e.g.
# re-written mounted files) are picked up without a process restart.
CredentialsProvider = Callable[[], tuple[str | None, str | None, str | None]]
class S3Backend:
"""Storage SPI implementation over an S3-compatible object store."""
def __init__(
self,
config: S3BackendConfig,
*,
backend_id: str = "s3",
client_factory: ClientFactory | None = None,
credentials_provider: CredentialsProvider | None = None,
chunk_size: int = _DEFAULT_CHUNK_SIZE,
) -> None:
if not config.bucket:
raise ValueError("S3 bucket is required")
self._config = config
self._backend_id = backend_id
self._client_factory = client_factory
self._credentials_provider = credentials_provider
self._chunk_size = chunk_size
@property
def backend_id(self) -> str:
return self._backend_id
def _client(self) -> AbstractAsyncContextManager[Any]:
if self._client_factory is not None:
return self._client_factory()
try:
import aioboto3 # type: ignore[import-not-found]
except ModuleNotFoundError as exc:
raise RuntimeError(
"S3Backend requires the 'aioboto3' package; install artifactstore[s3]"
) from exc
if self._credentials_provider is not None:
access_key_id, secret_access_key, session_token = self._credentials_provider()
else:
access_key_id = self._config.access_key_id
secret_access_key = self._config.secret_access_key
session_token = self._config.session_token
session = aioboto3.Session(
aws_access_key_id=access_key_id,
aws_secret_access_key=secret_access_key,
aws_session_token=session_token,
region_name=self._config.region,
)
return cast(
AbstractAsyncContextManager[Any],
session.client("s3", endpoint_url=self._config.endpoint_url),
)
def _object_key(self, content_address: ContentAddress) -> str:
digest = content_address.to_digest()
if len(digest.hex) < 4:
raise ValueError(f"digest hex too short for sharded layout: {content_address}")
key = f"{digest.algorithm}/{digest.hex[0:2]}/{digest.hex[2:4]}/{digest.hex}"
prefix = self._config.key_prefix.strip("/")
return f"{prefix}/{key}" if prefix else key
async def put(
self,
content_address: ContentAddress,
stream: AsyncIterator[bytes],
*,
size_hint: int | None = None,
) -> StorageReceipt:
key = self._object_key(content_address)
if size_hint is not None and size_hint >= self._config.multipart_threshold_bytes:
return await self._put_multipart(content_address, key, stream, size_hint=size_hint)
data = bytearray()
async for chunk in stream:
data.extend(chunk)
async with self._client() as client:
await client.put_object(
Bucket=self._config.bucket,
Key=key,
Body=bytes(data),
**self._put_options(),
)
return StorageReceipt(
backend_id=self._backend_id,
content_address=content_address,
object_key=key,
size_bytes=len(data),
)
async def _put_multipart(
self,
content_address: ContentAddress,
key: str,
stream: AsyncIterator[bytes],
*,
size_hint: int,
) -> StorageReceipt:
async with self._client() as client:
created = await client.create_multipart_upload(
Bucket=self._config.bucket,
Key=key,
**self._put_options(),
)
upload_id = created["UploadId"]
parts: list[dict[str, Any]] = []
part_number = 1
buffered = bytearray()
try:
async for chunk in stream:
buffered.extend(chunk)
while len(buffered) >= self._config.multipart_chunk_bytes:
part = bytes(buffered[: self._config.multipart_chunk_bytes])
del buffered[: self._config.multipart_chunk_bytes]
uploaded = await client.upload_part(
Bucket=self._config.bucket,
Key=key,
UploadId=upload_id,
PartNumber=part_number,
Body=part,
)
parts.append({"ETag": uploaded["ETag"], "PartNumber": part_number})
part_number += 1
if buffered:
uploaded = await client.upload_part(
Bucket=self._config.bucket,
Key=key,
UploadId=upload_id,
PartNumber=part_number,
Body=bytes(buffered),
)
parts.append({"ETag": uploaded["ETag"], "PartNumber": part_number})
await client.complete_multipart_upload(
Bucket=self._config.bucket,
Key=key,
UploadId=upload_id,
MultipartUpload={"Parts": parts},
)
except Exception:
await client.abort_multipart_upload(
Bucket=self._config.bucket,
Key=key,
UploadId=upload_id,
)
raise
return StorageReceipt(
backend_id=self._backend_id,
content_address=content_address,
object_key=key,
size_bytes=size_hint,
)
async def get(
self,
content_address: ContentAddress,
*,
byte_range: tuple[int, int] | None = None,
) -> AsyncIterator[bytes]:
key = self._object_key(content_address)
async def iterator() -> AsyncIterator[bytes]:
kwargs: dict[str, Any] = {"Bucket": self._config.bucket, "Key": key}
if byte_range is not None:
start, end = byte_range
kwargs["Range"] = f"bytes={start}-{end}"
async with self._client() as client:
try:
response = await client.get_object(**kwargs)
except Exception as exc:
if _is_not_found(exc):
raise ObjectNotFoundError(str(content_address)) from exc
raise
body = response["Body"]
while True:
chunk = await body.read(self._chunk_size)
if not chunk:
break
yield chunk
return iterator()
async def head(self, content_address: ContentAddress) -> StorageObjectMetadata:
key = self._object_key(content_address)
async with self._client() as client:
try:
response = await client.head_object(Bucket=self._config.bucket, Key=key)
except Exception as exc:
if _is_not_found(exc):
raise ObjectNotFoundError(str(content_address)) from exc
raise
return StorageObjectMetadata(
backend_id=self._backend_id,
content_address=content_address,
object_key=key,
size_bytes=int(response["ContentLength"]),
)
async def delete(self, content_address: ContentAddress) -> DeletionResult:
key = self._object_key(content_address)
async with self._client() as client:
await client.delete_object(Bucket=self._config.bucket, Key=key)
return DeletionResult(
backend_id=self._backend_id,
content_address=content_address,
deleted=True,
)
async def health(self) -> BackendStatus:
try:
async with self._client() as client:
await client.head_bucket(Bucket=self._config.bucket)
except Exception as exc:
return BackendStatus(
backend_id=self._backend_id,
healthy=False,
detail=f"{type(exc).__name__}: {exc}",
)
return BackendStatus(backend_id=self._backend_id, healthy=True, detail="ok")
def _put_options(self) -> dict[str, str]:
options: dict[str, str] = {}
if self._config.storage_class:
options["StorageClass"] = self._config.storage_class
if self._config.sse:
options["ServerSideEncryption"] = self._config.sse
return options
def _is_not_found(exc: Exception) -> bool:
response = getattr(exc, "response", None)
if isinstance(response, dict):
code = response.get("Error", {}).get("Code")
return str(code) in {"404", "NoSuchKey", "NotFound"}
return False

View File

@@ -0,0 +1,8 @@
{
"reviewed_logs": [
"raw/session/transcript.txt"
],
"warnings": [
"Repository returned one optional capability warning."
]
}

View File

@@ -0,0 +1,6 @@
{
"repositoryId": "fixture-repo",
"capabilities": {
"capabilityQuery": "metadataonly"
}
}

View File

@@ -0,0 +1,3 @@
GET /cmis/browser
200 OK
Repository info collected for fixture.

View File

@@ -0,0 +1,12 @@
{
"package_version": 1,
"files": [
{ "path": "run.json", "kind": "run-metadata" },
{ "path": "retention-summary.json", "kind": "retention-summary" },
{ "path": "reports/report.md", "kind": "report" },
{ "path": "scorecards/cmis-scorecard.json", "kind": "scorecard" },
{ "path": "logs/log-review-summary.json", "kind": "log-review" },
{ "path": "raw/session/transcript.txt", "kind": "raw-artifact" },
{ "path": "raw/session/browser-response.json", "kind": "raw-artifact" }
]
}

View File

@@ -0,0 +1,3 @@
# Guide-board CMIS Assessment
Fixture run `gb-fixture-001` completed with one warning and no failed checks.

View File

@@ -0,0 +1,17 @@
{
"retention_class": "release-evidence",
"report_paths": [
"reports/report.md",
"scorecards/cmis-scorecard.json",
"logs/log-review-summary.json"
],
"evidence_counts": {
"raw_artifacts": 2,
"reports": 3
},
"finding_counts": {
"pass": 17,
"warning": 1,
"fail": 0
}
}

10
tests/fixtures/guide-board/run.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"run_id": "gb-fixture-001",
"target_profile_ref": "open-cmis-tck:browser-binding",
"assessment_profile_ref": "guide-board:cmis-assessment:v1",
"result_status": "passed-with-findings",
"source_commits": {
"guide-board": "1234567890abcdef",
"open-cmis-tck": "abcdef1234567890"
}
}

View File

@@ -0,0 +1,7 @@
{
"scorecard": "cmis-browser-binding",
"checks": 18,
"passed": 17,
"warnings": 1,
"failed": 0
}

View File

@@ -2,15 +2,17 @@
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any
import pytest
from sqlalchemy import create_engine, insert, inspect
from sqlalchemy import create_engine, insert, inspect, select
from typer.testing import CliRunner
from artifactstore.cli import app as cli_app
from artifactstore.db.schema import metadata, retention_classes
from artifactstore.db.schema import metadata, retention_classes, storage_locations
from artifactstore.db.seed import RETENTION_CLASS_SEEDS
REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -84,3 +86,292 @@ def test_cli_health_reports_ok(
assert payload["status"] == "ok"
assert payload["db"]["healthy"] is True
assert payload["backend"]["healthy"] is True
def test_cli_push_uses_http_api(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
source = tmp_path / "source"
source.mkdir()
(source / "a.txt").write_text("alpha", encoding="utf-8")
calls: list[tuple[str, str, dict[str, Any]]] = []
multipart_calls: list[tuple[str, dict[str, str], bytes]] = []
def fake_http_json(
method: str,
base_url: str,
path: str,
token: str,
payload: dict[str, Any],
) -> dict[str, Any]:
calls.append((method, path, payload))
assert base_url == "http://api.test"
assert token == "secret"
if path == "/packages":
return {"id": "pkg-1"}
if path == "/packages/pkg-1/finalize":
return {"manifest_digest": "blake3:abc"}
raise AssertionError(f"unexpected JSON request: {method} {path}")
def fake_http_multipart(
base_url: str,
path: str,
token: str,
*,
fields: dict[str, str],
file_field: str,
file_name: str,
file_content_type: str,
file_bytes: bytes,
) -> dict[str, Any]:
assert base_url == "http://api.test"
assert token == "secret"
assert path == "/packages/pkg-1/files"
assert file_field == "file"
assert file_name == "a.txt"
assert file_content_type == "text/plain"
multipart_calls.append((path, fields, file_bytes))
return {"id": "file-1"}
monkeypatch.setattr("artifactstore.cli._http_json", fake_http_json)
monkeypatch.setattr("artifactstore.cli._http_multipart", fake_http_multipart)
result = runner.invoke(
cli_app,
[
"push",
str(source),
"--producer",
"prod",
"--subject",
"sub",
"--api-url",
"http://api.test",
"--token",
"secret",
],
)
assert result.exit_code == 0, result.output
assert json.loads(result.output) == {
"package_id": "pkg-1",
"manifest_digest": "blake3:abc",
"files": 1,
}
assert calls[0][1] == "/packages"
assert calls[1][1] == "/packages/pkg-1/finalize"
assert multipart_calls == [
(
"/packages/pkg-1/files",
{"relative_path": "a.txt", "media_type": "text/plain"},
b"alpha",
)
]
def test_cli_manifest_fetches_json_projection(
runner: CliRunner,
monkeypatch: pytest.MonkeyPatch,
) -> None:
def fake_http_bytes(
method: str,
base_url: str,
path: str,
token: str,
*,
body: bytes | None = None,
headers: dict[str, str] | None = None,
) -> bytes:
assert method == "GET"
assert base_url == "http://api.test"
assert path == "/packages/pkg-1/manifest.json"
assert token == "secret"
assert body is None
assert headers == {"Accept": "application/json"}
return b'{"manifest_version":1}'
monkeypatch.setattr("artifactstore.cli._http_bytes", fake_http_bytes)
result = runner.invoke(
cli_app,
[
"manifest",
"pkg-1",
"--api-url",
"http://api.test",
"--token",
"secret",
],
)
assert result.exit_code == 0, result.output
assert json.loads(result.output) == {"manifest_version": 1}
def test_cli_retention_sweep_marks_expired_package(
runner: CliRunner,
env_db: Path,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
sync_engine = create_engine(f"sqlite:///{env_db}", future=True)
metadata.create_all(sync_engine)
with sync_engine.begin() as conn:
conn.execute(insert(retention_classes), [dict(s) for s in RETENTION_CLASS_SEEDS])
sync_engine.dispose()
retention_config = tmp_path / "retention.toml"
retention_config.write_text(
'[retention_classes.transient]\ndefault_duration_seconds = 0\n',
encoding="utf-8",
)
monkeypatch.setenv("ARTIFACTSTORE_RETENTION_CONFIG_PATH", str(retention_config))
async def create_expired_package() -> str:
from artifactstore.app import build_registry
from artifactstore.config import get_settings
registry = build_registry(get_settings())
try:
package_id = await registry.create_package(
name="expired",
producer="tests",
subject="cli-sweep",
retention_class="transient",
actor="ops",
)
finally:
await registry.dispose()
return str(package_id)
package_id = asyncio.run(create_expired_package())
result = runner.invoke(cli_app, ["retention", "sweep"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload == {"marked_package_ids": [package_id], "marked_count": 1}
def test_cli_retention_gc_collects_eligible_package(
runner: CliRunner,
env_db: Path,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
sync_engine = create_engine(f"sqlite:///{env_db}", future=True)
metadata.create_all(sync_engine)
with sync_engine.begin() as conn:
conn.execute(insert(retention_classes), [dict(s) for s in RETENTION_CLASS_SEEDS])
sync_engine.dispose()
retention_config = tmp_path / "retention.toml"
retention_config.write_text(
'[retention_classes.transient]\ndefault_duration_seconds = 0\n',
encoding="utf-8",
)
monkeypatch.setenv("ARTIFACTSTORE_RETENTION_CONFIG_PATH", str(retention_config))
async def create_expired_file() -> None:
from collections.abc import AsyncIterator
from datetime import UTC, datetime, timedelta
from artifactstore.app import build_registry
from artifactstore.config import get_settings
async def stream() -> AsyncIterator[bytes]:
yield b"collect-me"
registry = build_registry(get_settings())
try:
package_id = await registry.create_package(
name="collect",
producer="tests",
subject="cli-gc",
retention_class="transient",
actor="ops",
)
await registry.ingest_file(
package_id,
relative_path="collect.bin",
media_type="application/octet-stream",
stream=stream(),
actor="ops",
)
await registry.finalize_package(package_id, actor="ops")
await registry.sweep_deletion_eligibility(
now=datetime.now(UTC) + timedelta(seconds=1)
)
finally:
await registry.dispose()
asyncio.run(create_expired_file())
result = runner.invoke(cli_app, ["retention", "gc"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["released_location_count"] == 1
assert payload["delete_attempted_object_count"] == 1
assert payload["deleted_object_count"] == 1
assert payload["results"][0]["object_deleted"] is True
sync_engine = create_engine(f"sqlite:///{env_db}", future=True)
with sync_engine.connect() as conn:
status = conn.execute(select(storage_locations.c.status)).scalar_one()
sync_engine.dispose()
assert status == "deleted"
def test_cli_storage_verify_marks_local_location_verified(
runner: CliRunner,
env_db: Path,
) -> None:
sync_engine = create_engine(f"sqlite:///{env_db}", future=True)
metadata.create_all(sync_engine)
with sync_engine.begin() as conn:
conn.execute(insert(retention_classes), [dict(s) for s in RETENTION_CLASS_SEEDS])
sync_engine.dispose()
async def create_stored_file() -> None:
from collections.abc import AsyncIterator
from artifactstore.app import build_registry
from artifactstore.config import get_settings
async def stream() -> AsyncIterator[bytes]:
yield b"verify-me"
registry = build_registry(get_settings())
try:
package_id = await registry.create_package(
name="verify",
producer="tests",
subject="storage",
retention_class="raw-evidence",
actor="ops",
)
await registry.ingest_file(
package_id,
relative_path="verify.txt",
media_type="text/plain",
stream=stream(),
actor="ops",
)
finally:
await registry.dispose()
asyncio.run(create_stored_file())
result = runner.invoke(cli_app, ["storage", "verify", "--backend", "local"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["verified_count"] == 1
assert payload["failed_count"] == 0
assert payload["results"][0]["verified"] is True
sync_engine = create_engine(f"sqlite:///{env_db}", future=True)
with sync_engine.connect() as conn:
status = conn.execute(select(storage_locations.c.status)).scalar_one()
sync_engine.dispose()
assert status == "verified"

View File

@@ -0,0 +1,206 @@
"""Garbage collection integration tests (ARTIFACT-STORE-WP-0006)."""
from __future__ import annotations
from collections.abc import AsyncIterator
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import UUID
import pytest
import pytest_asyncio
from sqlalchemy import insert, select
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from artifactstore.dataplane import InProcessDataPlane
from artifactstore.db.schema import (
metadata,
retention_classes,
storage_locations,
)
from artifactstore.db.seed import RETENTION_CLASS_SEEDS
from artifactstore.events import RegistryViewWriter, replay
from artifactstore.registry import FileNotFoundError, Registry
from artifactstore.retention import RetentionPolicy
from artifactstore.storage import LocalBackend
@pytest_asyncio.fixture
async def engine(tmp_path: Path) -> AsyncIterator[AsyncEngine]:
db_path = tmp_path / "gc.db"
eng = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
async with eng.begin() as conn:
await conn.run_sync(metadata.create_all)
for seed in RETENTION_CLASS_SEEDS:
await conn.execute(insert(retention_classes).values(**seed))
yield eng
await eng.dispose()
@pytest.fixture
def view_writer() -> RegistryViewWriter:
return RegistryViewWriter()
@pytest.fixture
def registry(
engine: AsyncEngine,
tmp_path: Path,
view_writer: RegistryViewWriter,
) -> Registry:
backend = LocalBackend(tmp_path / "storage", backend_id="local")
dataplane = InProcessDataPlane(backend, tmp_dir=tmp_path / "dp-tmp")
return Registry(
engine,
dataplane,
view_writer,
RetentionPolicy({"transient": 0}),
)
async def _stream(data: bytes) -> AsyncIterator[bytes]:
yield data
async def _consume(it: AsyncIterator[bytes]) -> bytes:
out = bytearray()
async for chunk in it:
out.extend(chunk)
return bytes(out)
async def _package_with_file(
registry: Registry,
*,
name: str,
retention_class: str,
data: bytes,
) -> tuple[UUID, UUID]:
package_id = await registry.create_package(
name=name,
producer="tests",
subject=name,
retention_class=retention_class,
actor="ops",
)
file_id = await registry.ingest_file(
package_id,
relative_path="payload.bin",
media_type="application/octet-stream",
stream=_stream(data),
actor="ops",
)
await registry.finalize_package(package_id, actor="ops")
return package_id, file_id
async def _location_statuses(engine: AsyncEngine) -> dict[UUID, str]:
async with engine.connect() as conn:
rows = (
await conn.execute(
select(
storage_locations.c.artifact_file_id,
storage_locations.c.status,
)
)
).all()
return {row.artifact_file_id: row.status for row in rows}
async def test_gc_deletes_unique_expired_object_and_replays(
registry: Registry,
engine: AsyncEngine,
view_writer: RegistryViewWriter,
) -> None:
package_id, file_id = await _package_with_file(
registry,
name="unique",
retention_class="transient",
data=b"unique bytes",
)
marked = await registry.sweep_deletion_eligibility(
now=datetime.now(UTC) + timedelta(seconds=1)
)
assert marked == [package_id]
results = await registry.collect_garbage()
assert len(results) == 1
assert results[0].file_id == file_id
assert results[0].object_delete_attempted is True
assert results[0].object_deleted is True
assert results[0].ref_count_before == 1
assert results[0].ref_count_after == 0
with pytest.raises(FileNotFoundError):
await registry.get_file(file_id)
assert await registry.collect_garbage() == []
pre = await _location_statuses(engine)
await replay(engine, view_writer, reset=True)
post = await _location_statuses(engine)
assert pre == post == {file_id: "deleted"}
package = await registry.get_package(package_id)
assert package.status == "garbage_collected"
async def test_gc_releases_shared_reference_without_deleting_retained_bytes(
registry: Registry,
engine: AsyncEngine,
) -> None:
data = b"shared bytes"
expired_package, expired_file = await _package_with_file(
registry,
name="expired",
retention_class="transient",
data=data,
)
retained_package, retained_file = await _package_with_file(
registry,
name="retained",
retention_class="raw-evidence",
data=data,
)
marked = await registry.sweep_deletion_eligibility(
now=datetime.now(UTC) + timedelta(seconds=1)
)
assert marked == [expired_package]
results = await registry.collect_garbage()
assert len(results) == 1
assert results[0].package_id == expired_package
assert results[0].object_delete_attempted is False
assert results[0].object_deleted is False
assert results[0].ref_count_before == 2
assert results[0].ref_count_after == 1
statuses = await _location_statuses(engine)
assert statuses[expired_file] == "deleted"
assert statuses[retained_file] == "recorded"
assert (await registry.get_package(expired_package)).status == "garbage_collected"
assert (await registry.get_package(retained_package)).status == "finalized"
with pytest.raises(FileNotFoundError):
await registry.get_file(expired_file)
retained_stream = await registry.get_file(retained_file)
assert await _consume(retained_stream) == data
async def test_gc_respects_active_hold(registry: Registry, engine: AsyncEngine) -> None:
package_id, file_id = await _package_with_file(
registry,
name="held",
retention_class="transient",
data=b"held bytes",
)
await registry.apply_retention_hold(package_id, reason="legal hold", actor="ops")
marked = await registry.sweep_deletion_eligibility(
now=datetime.now(UTC) + timedelta(seconds=1)
)
assert marked == []
assert await registry.collect_garbage() == []
assert await _location_statuses(engine) == {file_id: "recorded"}
stream = await registry.get_file(file_id)
assert await _consume(stream) == b"held bytes"

View File

@@ -0,0 +1,110 @@
"""Guide-board pilot ingestion tests (ARTIFACT-STORE-WP-0005)."""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from pathlib import Path
from uuid import UUID
import pytest
import pytest_asyncio
from sqlalchemy import create_engine, insert
from sqlalchemy.ext.asyncio import create_async_engine
from typer.testing import CliRunner
from artifactstore.cli import app as cli_app
from artifactstore.dataplane import InProcessDataPlane
from artifactstore.db.schema import metadata, retention_classes
from artifactstore.db.seed import RETENTION_CLASS_SEEDS
from artifactstore.events import RegistryViewWriter
from artifactstore.manifest import decode as manifest_decode
from artifactstore.pilots.guide_board import GUIDE_BOARD_SCHEMA_SLUG, ingest_run
from artifactstore.registry import Registry
from artifactstore.storage import LocalBackend
REPO_ROOT = Path(__file__).resolve().parents[2]
FIXTURE = REPO_ROOT / "tests" / "fixtures" / "guide-board"
SCHEMA = REPO_ROOT / "schemas" / "guide-board.run.v1.json"
@pytest_asyncio.fixture
async def registry(tmp_path: Path) -> AsyncIterator[Registry]:
db_path = tmp_path / "guide-board.db"
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
async with engine.begin() as conn:
await conn.run_sync(metadata.create_all)
for seed in RETENTION_CLASS_SEEDS:
await conn.execute(insert(retention_classes).values(**seed))
backend = LocalBackend(tmp_path / "storage", backend_id="local")
reg = Registry(engine, InProcessDataPlane(backend), RegistryViewWriter())
try:
yield reg
finally:
await reg.dispose()
async def _consume(stream: AsyncIterator[bytes]) -> bytes:
out = bytearray()
async for chunk in stream:
out.extend(chunk)
return bytes(out)
async def test_guide_board_library_ingest_is_idempotent_and_downloadable(
registry: Registry,
) -> None:
schema = json.loads(SCHEMA.read_text(encoding="utf-8"))
await registry.register_metadata_schema(slug=GUIDE_BOARD_SCHEMA_SLUG, json_schema=schema)
first = await ingest_run(FIXTURE, registry=registry)
second = await ingest_run(FIXTURE, registry=registry)
assert first.package_id
assert first.manifest_digest.startswith("blake3:")
assert first.manifest_digest == second.manifest_digest
assert second.reused_existing is True
manifest = manifest_decode(
await registry.get_manifest_bytes(UUID(first.package_id), format="cbor")
)
assert manifest.package.producer == "guide-board"
assert manifest.package.metadata_schema_id is not None
assert manifest.retention_summary.retention_class == "release-evidence"
assert len(manifest.files) == 8
for file_entry in manifest.files:
stream = await registry.get_file(UUID(file_entry.id))
assert await _consume(stream) == (FIXTURE / file_entry.relative_path).read_bytes()
state = await registry.get_retention_state(UUID(first.package_id))
assert state.effective_class == "release-evidence"
def test_guide_board_cli_ingest_outputs_package_and_digest(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
db_path = tmp_path / "guide-board-cli.db"
storage_root = tmp_path / "storage"
storage_root.mkdir()
sync_engine = create_engine(f"sqlite:///{db_path}", future=True)
metadata.create_all(sync_engine)
with sync_engine.begin() as conn:
conn.execute(insert(retention_classes), [dict(s) for s in RETENTION_CLASS_SEEDS])
sync_engine.dispose()
monkeypatch.setenv("ARTIFACTSTORE_DATABASE_URL", f"sqlite+aiosqlite:///{db_path}")
monkeypatch.setenv("ARTIFACTSTORE_STORAGE_LOCAL_ROOT", str(storage_root))
result = CliRunner().invoke(
cli_app,
["guide-board", "ingest", str(FIXTURE), "--schema", str(SCHEMA)],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["package_id"]
assert payload["manifest_digest"].startswith("blake3:")
assert payload["file_count"] == 8
assert payload["reused_existing"] is False

View File

@@ -0,0 +1,240 @@
"""HTTP API integration tests for ARTIFACT-STORE-WP-0002."""
from __future__ import annotations
import tempfile
from pathlib import Path
from typing import Any
import cbor2
from fastapi.testclient import TestClient
from hypothesis import HealthCheck, given
from hypothesis import settings as hypothesis_settings
from hypothesis import strategies as st
from sqlalchemy import create_engine, insert
from artifactstore.api.http import create_app
from artifactstore.config import Settings
from artifactstore.db.schema import metadata, retention_classes
from artifactstore.db.seed import RETENTION_CLASS_SEEDS
from artifactstore.identity import digest_bytes
AUTH = {"Authorization": "Bearer test-token"}
def _settings(root: Path) -> Settings:
db_path = root / "http-api.db"
storage_root = root / "storage"
storage_root.mkdir(parents=True, exist_ok=True)
sync_engine = create_engine(f"sqlite:///{db_path}", future=True)
metadata.create_all(sync_engine)
with sync_engine.begin() as conn:
conn.execute(insert(retention_classes), [dict(s) for s in RETENTION_CLASS_SEEDS])
sync_engine.dispose()
return Settings(
database_url=f"sqlite+aiosqlite:///{db_path}",
storage_local_root=str(storage_root),
log_level="INFO",
auth_tokens="test-token",
)
def _create_package(client: TestClient, *, name: str = "pkg") -> str:
resp = client.post(
"/packages",
headers=AUTH,
json={
"name": name,
"producer": "guide-board",
"subject": "run-42",
"retention_class": "raw-evidence",
"metadata": {"run_id": "r-42", "kind": "integration"},
},
)
assert resp.status_code == 201, resp.text
return str(resp.json()["id"])
def _upload_file(client: TestClient, package_id: str, rel_path: str, data: bytes) -> dict[str, Any]:
resp = client.post(
f"/packages/{package_id}/files",
headers=AUTH,
data={"relative_path": rel_path, "media_type": "application/octet-stream"},
files={"file": (Path(rel_path).name, data, "application/octet-stream")},
)
assert resp.status_code == 201, resp.text
return dict(resp.json())
def test_http_surface_ingest_finalize_download_and_events(tmp_path: Path) -> None:
app = create_app(_settings(tmp_path))
with TestClient(app) as client:
unauth = client.get("/packages")
assert unauth.status_code == 401
assert unauth.headers["content-type"].startswith("application/problem+json")
assert client.get("/openapi.json").status_code == 200
assert client.get("/docs").status_code == 200
assert client.get("/backends", headers=AUTH).json()["backends"][0]["backend_id"] == "local"
assert client.get("/retention-classes", headers=AUTH).json()["retention_classes"]
package_id = _create_package(client)
listing = client.get(
"/packages",
headers=AUTH,
params={
"producer": "guide-board",
"subject": "run-42",
"retention_class": "raw-evidence",
"metadata_key": "run_id",
"metadata_value": "r-42",
},
)
assert listing.status_code == 200
assert [p["id"] for p in listing.json()["packages"]] == [package_id]
data = b"hello artifact-store http api" * 64
file_record = _upload_file(client, package_id, "reports/hello.bin", data)
assert file_record["size_bytes"] == len(data)
assert file_record["digest_primary_hex"] == digest_bytes(data).primary.hex
finalized = client.post(f"/packages/{package_id}/finalize", headers=AUTH)
assert finalized.status_code == 200, finalized.text
assert finalized.json()["status"] == "finalized"
assert finalized.json()["manifest_digest"].startswith("blake3:")
manifest_cbor = client.get(
f"/packages/{package_id}/manifest",
headers={**AUTH, "Accept": "application/cbor"},
)
assert manifest_cbor.status_code == 200
manifest_payload = cbor2.loads(manifest_cbor.content)
assert manifest_payload["manifest_version"] == 1
assert manifest_payload["package"]["id"] == package_id
manifest_json = client.get(f"/packages/{package_id}/manifest.json", headers=AUTH)
assert manifest_json.status_code == 200
assert manifest_json.json()["files"][0]["relative_path"] == "reports/hello.bin"
file_id = file_record["id"]
metadata_resp = client.get(f"/files/{file_id}", headers=AUTH)
assert metadata_resp.status_code == 200
content_address = metadata_resp.json()["content_address"]
download = client.get(f"/files/{file_id}/download", headers=AUTH)
assert download.status_code == 200
assert download.content == data
assert download.headers["etag"] == f'"{content_address}"'
partial = client.get(
f"/files/{file_id}/download",
headers={**AUTH, "Range": "bytes=6-17"},
)
assert partial.status_code == 206
assert partial.headers["content-range"] == f"bytes 6-17/{len(data)}"
assert partial.content == data[6:18]
not_modified = client.get(
f"/files/{file_id}/download",
headers={**AUTH, "If-None-Match": f'"{content_address}"'},
)
assert not_modified.status_code == 304
events_json = client.get(
"/events",
headers={**AUTH, "Accept": "application/json"},
params={"since": 0, "limit": 10, "wait_seconds": 0},
)
assert events_json.status_code == 200
assert [e["event_type"] for e in events_json.json()["events"]] == [
"v1.package.created",
"v1.retention.default_applied",
"v1.file.ingested",
"v1.package.finalized",
]
events_cbor = client.get(
"/events",
headers={**AUTH, "Accept": "application/cbor"},
params={"since": 0, "limit": 10, "wait_seconds": 0},
)
assert events_cbor.status_code == 200
assert cbor2.loads(events_cbor.content)["events"][0]["sequence"] == 1
def test_http_scripted_50_file_package_flow(tmp_path: Path) -> None:
app = create_app(_settings(tmp_path))
with TestClient(app) as client:
package_id = _create_package(client, name="fifty")
uploaded: list[tuple[str, bytes, dict[str, Any]]] = []
for idx in range(50):
rel_path = f"bundle/file-{idx:02d}.bin"
payload = f"payload {idx:02d}:".encode() + bytes([idx]) * (idx + 1)
record = _upload_file(client, package_id, rel_path, payload)
uploaded.append((rel_path, payload, record))
finalized = client.post(f"/packages/{package_id}/finalize", headers=AUTH)
assert finalized.status_code == 200, finalized.text
for rel_path, payload, record in uploaded:
assert record["relative_path"] == rel_path
assert record["digest_primary_hex"] == digest_bytes(payload).primary.hex
downloaded = client.get(f"/files/{record['id']}/download", headers=AUTH)
assert downloaded.status_code == 200
assert downloaded.content == payload
events = client.get(
"/events",
headers={**AUTH, "Accept": "application/json"},
params={"since": 0, "limit": 100, "wait_seconds": 0},
)
assert events.status_code == 200
assert len(events.json()["events"]) == 53
assert events.json()["events"][-1]["event_type"] == "v1.package.finalized"
@given(
data=st.binary(min_size=1, max_size=512),
stem=st.text(alphabet=list("abcdefghijklmnopqrstuvwxyz0123456789_-"), min_size=1, max_size=24),
)
@hypothesis_settings(
max_examples=12,
deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_upload_session_lifecycle_property(data: bytes, stem: str) -> None:
with tempfile.TemporaryDirectory() as tmp:
app = create_app(_settings(Path(tmp)))
with TestClient(app) as client:
package_id = _create_package(client, name="upload-session")
opened = client.post(
"/uploads",
headers=AUTH,
json={"expected_size_bytes": len(data), "media_type": "application/octet-stream"},
)
assert opened.status_code == 201, opened.text
upload_url = opened.json()["content_upload_url"]
patched = client.patch(
upload_url,
headers={**AUTH, "Content-Range": f"bytes 0-{len(data) - 1}/{len(data)}"},
content=data,
)
assert patched.status_code == 200, patched.text
assert patched.json()["received_bytes"] == len(data)
completed = client.post(
f"{upload_url}/complete",
headers=AUTH,
json={
"package_id": package_id,
"relative_path": f"uploads/{stem}.bin",
"media_type": "application/octet-stream",
},
)
assert completed.status_code == 201, completed.text
file_id = completed.json()["id"]
downloaded = client.get(f"/files/{file_id}/download", headers=AUTH)
assert downloaded.status_code == 200
assert downloaded.content == data

View File

@@ -243,18 +243,19 @@ async def test_end_to_end_ingest_finalize_replay(
stream = await registry.get_file(fid)
assert await _consume(stream) == expected
# Tail events: 1 created + 3 ingested + 1 finalized = 5.
# Tail events: 1 created + 1 default retention + 3 ingested + 1 finalized = 6.
collected = []
async def _consume_tail() -> None:
async for evt in registry.tail_events(since_sequence=0, poll_interval_seconds=0.01):
collected.append(evt)
if len(collected) >= 5:
if len(collected) >= 6:
break
await asyncio.wait_for(_consume_tail(), timeout=5.0)
assert [e.event_type for e in collected] == [
"v1.package.created",
"v1.retention.default_applied",
"v1.file.ingested",
"v1.file.ingested",
"v1.file.ingested",

View File

@@ -0,0 +1,250 @@
"""Retention lifecycle integration tests for ARTIFACT-STORE-WP-0003."""
from __future__ import annotations
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from pathlib import Path
import cbor2
import pytest
import pytest_asyncio
from fastapi.testclient import TestClient
from sqlalchemy import insert
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from artifactstore.api.http import create_app
from artifactstore.config import Settings
from artifactstore.dataplane import InProcessDataPlane
from artifactstore.db.schema import metadata, retention_classes
from artifactstore.db.seed import RETENTION_CLASS_SEEDS
from artifactstore.events import RegistryViewWriter
from artifactstore.registry import Registry, RetentionStateError
from artifactstore.retention import RetentionPolicy
from artifactstore.storage import LocalBackend
AUTH = {"Authorization": "Bearer test-token"}
@pytest_asyncio.fixture
async def engine(tmp_path: Path) -> AsyncIterator[AsyncEngine]:
db_path = tmp_path / "retention.db"
eng = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
async with eng.begin() as conn:
await conn.run_sync(metadata.create_all)
for seed in RETENTION_CLASS_SEEDS:
await conn.execute(insert(retention_classes).values(**seed))
yield eng
await eng.dispose()
@pytest.fixture
def registry(engine: AsyncEngine, tmp_path: Path) -> Registry:
backend = LocalBackend(tmp_path / "store", backend_id="local")
dataplane = InProcessDataPlane(backend, tmp_dir=tmp_path / "dp-tmp")
return Registry(
engine,
dataplane,
RegistryViewWriter(),
RetentionPolicy({"transient": 0}),
)
def _http_settings(tmp_path: Path, retention_config_path: Path | None = None) -> Settings:
db_path = tmp_path / "retention-http.db"
storage_root = tmp_path / "storage"
storage_root.mkdir(parents=True, exist_ok=True)
from sqlalchemy import create_engine
sync_engine = create_engine(f"sqlite:///{db_path}", future=True)
metadata.create_all(sync_engine)
with sync_engine.begin() as conn:
conn.execute(insert(retention_classes), [dict(s) for s in RETENTION_CLASS_SEEDS])
sync_engine.dispose()
return Settings(
database_url=f"sqlite+aiosqlite:///{db_path}",
storage_local_root=str(storage_root),
log_level="INFO",
auth_tokens="test-token",
retention_config_path=str(retention_config_path or ""),
)
def _create_package(
client: TestClient,
*,
retention_class: str = "raw-evidence",
) -> dict[str, object]:
resp = client.post(
"/packages",
headers=AUTH,
json={
"name": "retention",
"producer": "tests",
"subject": "retention-subject",
"retention_class": retention_class,
"metadata": {},
},
)
assert resp.status_code == 201, resp.text
return dict(resp.json())
async def test_default_retention_and_permanent_record(registry: Registry) -> None:
transient_id = await registry.create_package(
name="short",
producer="tests",
subject="transient",
retention_class="transient",
actor="ops",
)
transient_state = await registry.get_retention_state(transient_id)
assert transient_state.current_expires_at is not None
assert transient_state.eligible_for_deletion is False
permanent_id = await registry.create_package(
name="forever",
producer="tests",
subject="permanent",
retention_class="permanent-record",
actor="ops",
)
permanent_state = await registry.get_retention_state(permanent_id)
assert permanent_state.current_expires_at is None
assert permanent_state.eligible_for_deletion is False
history = await registry.retention_history(transient_id)
assert [event.event_type for event in history] == ["v1.retention.default_applied"]
assert cbor2.loads(history[0].payload)["default_duration_seconds"] == 0
async def test_retention_extension_requires_later_expiry(registry: Registry) -> None:
package_id = await registry.create_package(
name="extend",
producer="tests",
subject="extension",
retention_class="transient",
actor="ops",
)
current = await registry.get_retention_state(package_id)
assert current.current_expires_at is not None
with pytest.raises(RetentionStateError, match="strictly later"):
await registry.extend_retention(
package_id,
new_expires_at=current.current_expires_at,
reason="not later",
actor="ops",
)
new_expiry = current.current_expires_at + timedelta(days=1)
extended = await registry.extend_retention(
package_id,
new_expires_at=new_expiry,
reason="needed for quarterly review",
actor="ops",
)
assert extended.current_expires_at == new_expiry
history = await registry.retention_history(package_id)
assert [event.event_type for event in history] == [
"v1.retention.default_applied",
"v1.retention.extended",
]
async def test_hold_release_and_sweeper_eligibility_transition(registry: Registry) -> None:
package_id = await registry.create_package(
name="held",
producer="tests",
subject="hold-release",
retention_class="transient",
actor="ops",
)
initial = await registry.get_retention_state(package_id)
assert initial.current_expires_at is not None
after_expiry = initial.current_expires_at + timedelta(seconds=5)
hold_id = await registry.apply_retention_hold(
package_id,
reason="quarterly hold",
actor="ops",
)
held = await registry.get_retention_state(package_id)
assert held.active_hold_id == hold_id
assert await registry.sweep_deletion_eligibility(now=after_expiry) == []
still_held = await registry.get_retention_state(package_id)
assert still_held.eligible_for_deletion is False
released = await registry.release_retention_hold(
package_id,
hold_id,
reason="hold complete",
actor="ops",
now=after_expiry,
)
assert released.active_hold_id is None
assert released.eligible_for_deletion is True
assert await registry.sweep_deletion_eligibility(now=after_expiry) == []
history = await registry.retention_history(package_id)
assert [event.event_type for event in history] == [
"v1.retention.default_applied",
"v1.retention.hold_applied",
"v1.retention.hold_released",
"v1.retention.deletion_eligible",
]
def test_http_retention_controls_and_history_formats(tmp_path: Path) -> None:
app = create_app(_http_settings(tmp_path))
with TestClient(app) as client:
package = _create_package(client)
package_id = str(package["id"])
current_expires_at = datetime.fromisoformat(str(package["expires_at"]))
new_expiry = current_expires_at + timedelta(days=7)
extended = client.post(
f"/packages/{package_id}/retention/extensions",
headers=AUTH,
json={
"new_expires_at": new_expiry.isoformat(),
"reason": "retain for release signoff",
},
)
assert extended.status_code == 200, extended.text
assert extended.json()["current_expires_at"] == new_expiry.isoformat()
hold = client.post(
f"/packages/{package_id}/retention/holds",
headers=AUTH,
json={"reason": "external audit"},
)
assert hold.status_code == 201, hold.text
hold_id = hold.json()["hold_id"]
released = client.post(
f"/packages/{package_id}/retention/holds/{hold_id}/release",
headers=AUTH,
json={"reason": "audit complete"},
)
assert released.status_code == 200, released.text
assert released.json()["active_hold_id"] is None
history_json = client.get(f"/packages/{package_id}/retention/history", headers=AUTH)
assert history_json.status_code == 200
assert [event["event_type"] for event in history_json.json()["events"]] == [
"v1.retention.default_applied",
"v1.retention.extended",
"v1.retention.hold_applied",
"v1.retention.hold_released",
]
history_cbor = client.get(
f"/packages/{package_id}/retention/history",
headers={**AUTH, "Accept": "application/cbor"},
)
assert history_cbor.status_code == 200
assert cbor2.loads(history_cbor.content)["events"][1]["event_type"] == (
"v1.retention.extended"
)

View File

@@ -0,0 +1,104 @@
"""Opt-in live MinIO compatibility tests for the S3 backend."""
from __future__ import annotations
from collections.abc import AsyncIterator
from os import environ
from uuid import uuid4
import pytest
from artifactstore.identity import ContentAddress, digest_bytes
from artifactstore.storage import ObjectNotFoundError, S3Backend, S3BackendConfig
pytestmark = pytest.mark.integration
_MINIO_ENV_VARS = (
"ARTIFACTSTORE_MINIO_ENDPOINT_URL",
"ARTIFACTSTORE_MINIO_ACCESS_KEY",
"ARTIFACTSTORE_MINIO_SECRET_KEY",
"ARTIFACTSTORE_MINIO_BUCKET",
)
_MIB = 1024 * 1024
async def _stream(data: bytes, chunk_size: int = _MIB) -> AsyncIterator[bytes]:
for offset in range(0, len(data), chunk_size):
yield data[offset : offset + chunk_size]
async def _consume(stream: AsyncIterator[bytes]) -> bytes:
out = bytearray()
async for chunk in stream:
out.extend(chunk)
return bytes(out)
def _ca(data: bytes) -> ContentAddress:
return digest_bytes(data).primary.content_address
@pytest.fixture
def minio_backend() -> S3Backend:
missing = [name for name in _MINIO_ENV_VARS if not environ.get(name)]
if missing:
pytest.skip(f"set {', '.join(missing)} to run live MinIO compatibility tests")
config = S3BackendConfig(
endpoint_url=environ["ARTIFACTSTORE_MINIO_ENDPOINT_URL"],
region=environ.get("ARTIFACTSTORE_MINIO_REGION", "us-east-1"),
bucket=environ["ARTIFACTSTORE_MINIO_BUCKET"],
key_prefix=environ.get("ARTIFACTSTORE_MINIO_KEY_PREFIX", f"compat/{uuid4()}"),
access_key_id=environ["ARTIFACTSTORE_MINIO_ACCESS_KEY"],
secret_access_key=environ["ARTIFACTSTORE_MINIO_SECRET_KEY"],
session_token=environ.get("ARTIFACTSTORE_MINIO_SESSION_TOKEN") or None,
storage_class=environ.get("ARTIFACTSTORE_MINIO_STORAGE_CLASS") or None,
sse=environ.get("ARTIFACTSTORE_MINIO_SSE") or None,
multipart_threshold_bytes=5 * _MIB,
multipart_chunk_bytes=5 * _MIB,
)
return S3Backend(config, backend_id="minio-live", chunk_size=512 * 1024)
async def test_live_minio_round_trip_with_range(minio_backend: S3Backend) -> None:
data = f"artifact-store minio compatibility {uuid4()}".encode()
content_address = _ca(data)
try:
status = await minio_backend.health()
assert status.healthy, status.detail
receipt = await minio_backend.put(
content_address,
_stream(data, chunk_size=7),
size_hint=len(data),
)
assert receipt.backend_id == "minio-live"
assert receipt.size_bytes == len(data)
metadata = await minio_backend.head(content_address)
assert metadata.size_bytes == len(data)
stream = await minio_backend.get(content_address)
assert await _consume(stream) == data
ranged = await minio_backend.get(content_address, byte_range=(1, 9))
assert await _consume(ranged) == data[1:10]
finally:
await minio_backend.delete(content_address)
with pytest.raises(ObjectNotFoundError):
await minio_backend.head(content_address)
async def test_live_minio_multipart_upload(minio_backend: S3Backend) -> None:
data = (b"artifact-store-multipart-" + uuid4().bytes) * 200_000
content_address = _ca(data)
try:
receipt = await minio_backend.put(content_address, _stream(data), size_hint=len(data))
assert receipt.backend_id == "minio-live"
assert receipt.size_bytes == len(data)
tail = await minio_backend.get(content_address, byte_range=(len(data) - 16, len(data) - 1))
assert await _consume(tail) == data[-16:]
finally:
await minio_backend.delete(content_address)

View File

@@ -0,0 +1,39 @@
"""Storage configuration parsing tests."""
from __future__ import annotations
from pathlib import Path
import pytest
from artifactstore.config import parse_backend_routes, resolve_secret_ref
def test_parse_backend_routes_supports_wildcards() -> None:
routes = parse_backend_routes("guide-board:release-evidence=s3,*:*=local")
assert len(routes) == 2
assert routes[0].matches(producer="guide-board", retention_class="release-evidence")
assert not routes[0].matches(producer="guide-board", retention_class="raw-evidence")
assert routes[1].matches(producer="anything", retention_class="raw-evidence")
assert routes[0].backend_id == "s3"
def test_parse_backend_routes_rejects_invalid_entry() -> None:
with pytest.raises(ValueError):
parse_backend_routes("guide-board=s3")
def test_resolve_secret_ref_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ARTIFACTSTORE_TEST_SECRET", "secret")
assert resolve_secret_ref("env:ARTIFACTSTORE_TEST_SECRET") == "secret"
def test_resolve_secret_ref_from_file(tmp_path: Path) -> None:
secret_file = tmp_path / "secret"
secret_file.write_text("secret\n", encoding="utf-8")
assert resolve_secret_ref(f"file:{secret_file}") == "secret"
def test_resolve_secret_ref_rejects_literal() -> None:
with pytest.raises(ValueError):
resolve_secret_ref("literal-secret")

View File

@@ -158,6 +158,24 @@ async def test_ingest_hints_override_primary_algorithm(
assert result.primary_digest.hex == result.sha256_digest.hex
async def test_ingest_hints_route_to_named_backend(tmp_path: Path) -> None:
local = LocalBackend(tmp_path / "local", backend_id="local")
archive = LocalBackend(tmp_path / "archive", backend_id="archive")
dp = InProcessDataPlane(
{"local": local, "archive": archive},
default_backend_id="local",
)
result = await dp.ingest_stream(
_stream(b"route-me"),
hints=IngestHints(backend_id="archive"),
)
assert result.receipt.backend_id == "archive"
assert not (local.root / result.receipt.object_key).exists()
assert (archive.root / result.receipt.object_key).exists()
async def test_serve_missing_object_propagates_object_not_found(
dataplane: InProcessDataPlane,
) -> None:

View File

@@ -0,0 +1,250 @@
"""S3-compatible backend tests (ARTIFACT-STORE-WP-0004)."""
from __future__ import annotations
import sys
from collections.abc import AsyncIterator
from types import SimpleNamespace
from typing import Any
import pytest
from artifactstore.identity import ContentAddress, digest_bytes
from artifactstore.storage import ObjectNotFoundError, S3Backend, S3BackendConfig
async def _stream(data: bytes, chunk_size: int = 4) -> AsyncIterator[bytes]:
for i in range(0, len(data), chunk_size):
yield data[i : i + chunk_size]
async def _consume(stream: AsyncIterator[bytes]) -> bytes:
out = bytearray()
async for chunk in stream:
out.extend(chunk)
return bytes(out)
def _ca(data: bytes) -> ContentAddress:
return digest_bytes(data).primary.content_address
class FakeNotFoundError(Exception):
def __init__(self) -> None:
super().__init__("not found")
self.response = {"Error": {"Code": "NoSuchKey"}}
class FakeBody:
def __init__(self, data: bytes) -> None:
self._data = data
self._offset = 0
async def read(self, size: int) -> bytes:
if self._offset >= len(self._data):
return b""
chunk = self._data[self._offset : self._offset + size]
self._offset += len(chunk)
return chunk
class FakeS3Client:
def __init__(self) -> None:
self.objects: dict[str, bytes] = {}
self.calls: list[tuple[str, dict[str, Any]]] = []
self.uploads: dict[str, list[tuple[int, bytes]]] = {}
async def __aenter__(self) -> FakeS3Client:
return self
async def __aexit__(self, *_exc: object) -> None:
return None
async def put_object(self, **kwargs: Any) -> None:
self.calls.append(("put_object", kwargs))
self.objects[kwargs["Key"]] = kwargs["Body"]
async def create_multipart_upload(self, **kwargs: Any) -> dict[str, str]:
self.calls.append(("create_multipart_upload", kwargs))
upload_id = f"upload-{len(self.uploads) + 1}"
self.uploads[upload_id] = []
return {"UploadId": upload_id}
async def upload_part(self, **kwargs: Any) -> dict[str, str]:
self.calls.append(("upload_part", kwargs))
self.uploads[kwargs["UploadId"]].append((kwargs["PartNumber"], kwargs["Body"]))
return {"ETag": f"etag-{kwargs['PartNumber']}"}
async def complete_multipart_upload(self, **kwargs: Any) -> None:
self.calls.append(("complete_multipart_upload", kwargs))
parts = self.uploads[kwargs["UploadId"]]
self.objects[kwargs["Key"]] = b"".join(part for _num, part in sorted(parts))
async def abort_multipart_upload(self, **kwargs: Any) -> None:
self.calls.append(("abort_multipart_upload", kwargs))
async def get_object(self, **kwargs: Any) -> dict[str, FakeBody]:
self.calls.append(("get_object", kwargs))
try:
data = self.objects[kwargs["Key"]]
except KeyError as exc:
raise FakeNotFoundError from exc
range_header = kwargs.get("Range")
if range_header:
bounds = str(range_header).removeprefix("bytes=").split("-", maxsplit=1)
start = int(bounds[0])
end = int(bounds[1])
data = data[start : end + 1]
return {"Body": FakeBody(data)}
async def head_object(self, **kwargs: Any) -> dict[str, int]:
self.calls.append(("head_object", kwargs))
try:
data = self.objects[kwargs["Key"]]
except KeyError as exc:
raise FakeNotFoundError from exc
return {"ContentLength": len(data)}
async def delete_object(self, **kwargs: Any) -> None:
self.calls.append(("delete_object", kwargs))
self.objects.pop(kwargs["Key"], None)
async def head_bucket(self, **kwargs: Any) -> None:
self.calls.append(("head_bucket", kwargs))
@pytest.fixture
def fake_client() -> FakeS3Client:
return FakeS3Client()
@pytest.fixture
def backend(fake_client: FakeS3Client) -> S3Backend:
return S3Backend(
S3BackendConfig(
endpoint_url="http://minio.test",
region="us-east-1",
bucket="artifacts",
key_prefix="artifact-store",
storage_class="STANDARD",
sse="AES256",
multipart_threshold_bytes=8,
multipart_chunk_bytes=5,
),
client_factory=lambda: fake_client,
chunk_size=3,
)
async def test_put_get_head_delete_round_trip(
backend: S3Backend,
fake_client: FakeS3Client,
) -> None:
data = b"abc"
ca = _ca(data)
receipt = await backend.put(ca, _stream(data), size_hint=len(data))
digest = ca.to_digest()
assert receipt.object_key == (
f"artifact-store/{digest.algorithm}/{digest.hex[:2]}/{digest.hex[2:4]}/{digest.hex}"
)
assert fake_client.calls[0][0] == "put_object"
assert fake_client.calls[0][1]["StorageClass"] == "STANDARD"
assert fake_client.calls[0][1]["ServerSideEncryption"] == "AES256"
meta = await backend.head(ca)
assert meta.size_bytes == len(data)
stream = await backend.get(ca)
assert await _consume(stream) == data
await backend.delete(ca)
with pytest.raises(ObjectNotFoundError):
await backend.head(ca)
async def test_get_supports_range(backend: S3Backend, fake_client: FakeS3Client) -> None:
data = b"0123456789"
ca = _ca(data)
await backend.put(ca, _stream(data), size_hint=len(data))
stream = await backend.get(ca, byte_range=(2, 5))
assert await _consume(stream) == b"2345"
assert fake_client.calls[-1][1]["Range"] == "bytes=2-5"
async def test_put_uses_multipart_above_threshold(
backend: S3Backend,
fake_client: FakeS3Client,
) -> None:
data = b"abcdefghijkl"
ca = _ca(data)
receipt = await backend.put(ca, _stream(data), size_hint=len(data))
assert receipt.size_bytes == len(data)
assert [name for name, _kwargs in fake_client.calls] == [
"create_multipart_upload",
"upload_part",
"upload_part",
"upload_part",
"complete_multipart_upload",
]
stream = await backend.get(ca)
assert await _consume(stream) == data
async def test_health_uses_head_bucket(backend: S3Backend) -> None:
status = await backend.health()
assert status.healthy is True
assert status.backend_id == "s3"
def test_client_passes_session_token(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, object] = {}
class FakeSession:
def __init__(self, **kwargs: object) -> None:
captured.update(kwargs)
def client(self, *args: object, **kwargs: object) -> object:
return object()
monkeypatch.setitem(sys.modules, "aioboto3", SimpleNamespace(Session=FakeSession))
backend_with_token = S3Backend(
S3BackendConfig(
endpoint_url="http://minio.local:9000",
region="us-east-1",
bucket="bucket",
access_key_id="AKIA-temporary",
secret_access_key="temp-secret",
session_token="temp-session-token",
)
)
backend_with_token._client()
assert captured["aws_session_token"] == "temp-session-token"
def test_credentials_provider_re_resolves_per_client(
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen_tokens: list[object] = []
class FakeSession:
def __init__(self, **kwargs: object) -> None:
seen_tokens.append(kwargs.get("aws_session_token"))
def client(self, *args: object, **kwargs: object) -> object:
return object()
monkeypatch.setitem(sys.modules, "aioboto3", SimpleNamespace(Session=FakeSession))
rotation = iter(["token-1", "token-2"])
backend_rotating = S3Backend(
S3BackendConfig(
endpoint_url="http://minio.local:9000",
region="us-east-1",
bucket="bucket",
),
credentials_provider=lambda: ("key", "secret", next(rotation)),
)
backend_rotating._client()
backend_rotating._client()
assert seen_tokens == ["token-1", "token-2"]

681
uv.lock generated
View File

@@ -5,6 +5,182 @@ resolution-markers = [
"python_full_version >= '3.15'",
]
[[package]]
name = "aioboto3"
version = "15.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiobotocore", extra = ["boto3"] },
{ name = "aiofiles" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/01/92e9ab00f36e2899315f49eefcd5b4685fbb19016c7f19a9edf06da80bb0/aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979", size = 255069 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/3e/e8f5b665bca646d43b916763c901e00a07e40f7746c9128bdc912a089424/aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6", size = 35913 },
]
[[package]]
name = "aiobotocore"
version = "2.25.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "aioitertools" },
{ name = "botocore" },
{ name = "jmespath" },
{ name = "multidict" },
{ name = "python-dateutil" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/94/2e4ec48cf1abb89971cb2612d86f979a6240520f0a659b53a43116d344dc/aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc", size = 120560 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/2a/d275ec4ce5cd0096665043995a7d76f5d0524853c76a3d04656de49f8808/aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f", size = 86039 },
]
[package.optional-dependencies]
boto3 = [
{ name = "boto3" },
]
[[package]]
name = "aiofiles"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668 },
]
[[package]]
name = "aiohappyeyeballs"
version = "2.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062 },
]
[[package]]
name = "aiohttp"
version = "3.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
{ name = "aiosignal" },
{ name = "attrs" },
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402 },
{ url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310 },
{ url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448 },
{ url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854 },
{ url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884 },
{ url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034 },
{ url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054 },
{ url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278 },
{ url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795 },
{ url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397 },
{ url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504 },
{ url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806 },
{ url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707 },
{ url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121 },
{ url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580 },
{ url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771 },
{ url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873 },
{ url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073 },
{ url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882 },
{ url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270 },
{ url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841 },
{ url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088 },
{ url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564 },
{ url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998 },
{ url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918 },
{ url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657 },
{ url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907 },
{ url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565 },
{ url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018 },
{ url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416 },
{ url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881 },
{ url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572 },
{ url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137 },
{ url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953 },
{ url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479 },
{ url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077 },
{ url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688 },
{ url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094 },
{ url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662 },
{ url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748 },
{ url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723 },
{ url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531 },
{ url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718 },
{ url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918 },
{ url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014 },
{ url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398 },
{ url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018 },
{ url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462 },
{ url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824 },
{ url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898 },
{ url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114 },
{ url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541 },
{ url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776 },
{ url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329 },
{ url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293 },
{ url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756 },
{ url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052 },
{ url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888 },
{ url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679 },
{ url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021 },
{ url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574 },
{ url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773 },
{ url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001 },
{ url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809 },
{ url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320 },
{ url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077 },
{ url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476 },
{ url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347 },
{ url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465 },
{ url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423 },
{ url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906 },
{ url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095 },
{ url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222 },
{ url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922 },
{ url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035 },
{ url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512 },
{ url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571 },
{ url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159 },
{ url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409 },
{ url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166 },
{ url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255 },
{ url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640 },
]
[[package]]
name = "aioitertools"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182 },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "frozenlist" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 },
]
[[package]]
name = "aiosqlite"
version = "0.22.1"
@@ -91,6 +267,9 @@ dev = [
postgres = [
{ name = "psycopg", extra = ["binary"] },
]
s3 = [
{ name = "aioboto3" },
]
[package.dev-dependencies]
dev = [
@@ -104,6 +283,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aioboto3", marker = "extra == 's3'", specifier = ">=13.0" },
{ name = "aiosqlite", specifier = ">=0.20" },
{ name = "alembic", specifier = ">=1.13" },
{ name = "asyncpg", specifier = ">=0.29" },
@@ -214,6 +394,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062 },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 },
]
[[package]]
name = "blake3"
version = "1.0.8"
@@ -282,6 +471,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/5c/dbd00727a3dd165d7e0e8af40e630cd7e45d77b525a3218afaff8a87358e/blake3-1.0.8-cp314-cp314t-win_amd64.whl", hash = "sha256:421b99cdf1ff2d1bf703bc56c454f4b286fce68454dd8711abbcb5a0df90c19a", size = 215133 },
]
[[package]]
name = "boto3"
version = "1.40.61"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ed/f9/6ef8feb52c3cce5ec3967a535a6114b57ac7949fd166b0f3090c2b06e4e5/boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12", size = 111535 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/24/3bf865b07d15fea85b63504856e137029b6acbc73762496064219cdb265d/boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c", size = 139321 },
]
[[package]]
name = "botocore"
version = "1.40.61"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/28/a3/81d3a47c2dbfd76f185d3b894f2ad01a75096c006a2dd91f237dca182188/botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd", size = 14393956 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/c5/f6ce561004db45f0b847c2cd9b19c67c6bf348a82018a48cb718be6b58b0/botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7", size = 14055973 },
]
[[package]]
name = "cbor2"
version = "6.1.1"
@@ -368,6 +585,95 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683 },
]
[[package]]
name = "frozenlist"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782 },
{ url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594 },
{ url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448 },
{ url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411 },
{ url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014 },
{ url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909 },
{ url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049 },
{ url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485 },
{ url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619 },
{ url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320 },
{ url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820 },
{ url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518 },
{ url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096 },
{ url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985 },
{ url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591 },
{ url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102 },
{ url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717 },
{ url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651 },
{ url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417 },
{ url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391 },
{ url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048 },
{ url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549 },
{ url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833 },
{ url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363 },
{ url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314 },
{ url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365 },
{ url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763 },
{ url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110 },
{ url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717 },
{ url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628 },
{ url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882 },
{ url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676 },
{ url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235 },
{ url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742 },
{ url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725 },
{ url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533 },
{ url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506 },
{ url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161 },
{ url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676 },
{ url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638 },
{ url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067 },
{ url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101 },
{ url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901 },
{ url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395 },
{ url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659 },
{ url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492 },
{ url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034 },
{ url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749 },
{ url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127 },
{ url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698 },
{ url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749 },
{ url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298 },
{ url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015 },
{ url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038 },
{ url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130 },
{ url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845 },
{ url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131 },
{ url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542 },
{ url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308 },
{ url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210 },
{ url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972 },
{ url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536 },
{ url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330 },
{ url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627 },
{ url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238 },
{ url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738 },
{ url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739 },
{ url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186 },
{ url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196 },
{ url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830 },
{ url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289 },
{ url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318 },
{ url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814 },
{ url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762 },
{ url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470 },
{ url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042 },
{ url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148 },
{ url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676 },
{ url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451 },
{ url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507 },
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 },
]
[[package]]
name = "greenlet"
version = "3.5.0"
@@ -520,6 +826,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/d4/9a99bc15266a842bd14a1913afdb05182888ebab035666c1ce8a64537ca2/jcs-0.2.1-py3-none-any.whl", hash = "sha256:e23a3e1de60f832d33cd811bb9c3b3be79219cdf95f63b88f0972732c3fa8476", size = 7603 },
]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419 },
]
[[package]]
name = "librt"
version = "0.11.0"
@@ -676,6 +991,105 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
]
[[package]]
name = "multidict"
version = "6.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893 },
{ url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456 },
{ url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872 },
{ url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018 },
{ url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883 },
{ url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413 },
{ url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404 },
{ url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456 },
{ url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322 },
{ url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955 },
{ url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254 },
{ url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059 },
{ url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588 },
{ url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642 },
{ url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377 },
{ url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887 },
{ url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053 },
{ url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307 },
{ url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174 },
{ url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116 },
{ url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524 },
{ url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368 },
{ url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952 },
{ url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317 },
{ url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132 },
{ url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140 },
{ url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277 },
{ url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291 },
{ url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156 },
{ url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742 },
{ url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221 },
{ url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664 },
{ url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490 },
{ url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695 },
{ url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884 },
{ url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122 },
{ url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175 },
{ url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460 },
{ url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930 },
{ url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582 },
{ url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031 },
{ url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596 },
{ url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492 },
{ url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899 },
{ url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970 },
{ url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060 },
{ url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888 },
{ url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554 },
{ url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341 },
{ url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391 },
{ url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422 },
{ url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770 },
{ url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109 },
{ url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573 },
{ url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190 },
{ url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486 },
{ url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219 },
{ url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132 },
{ url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420 },
{ url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510 },
{ url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094 },
{ url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786 },
{ url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483 },
{ url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403 },
{ url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315 },
{ url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528 },
{ url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784 },
{ url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980 },
{ url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602 },
{ url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930 },
{ url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074 },
{ url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471 },
{ url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401 },
{ url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143 },
{ url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507 },
{ url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358 },
{ url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884 },
{ url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878 },
{ url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542 },
{ url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403 },
{ url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889 },
{ url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982 },
{ url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415 },
{ url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337 },
{ url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788 },
{ url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842 },
{ url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237 },
{ url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008 },
{ url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542 },
{ url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719 },
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319 },
]
[[package]]
name = "mypy"
version = "2.1.0"
@@ -756,6 +1170,100 @@ 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 = "propcache"
version = "0.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887 },
{ url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654 },
{ url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190 },
{ url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995 },
{ url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422 },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342 },
{ url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639 },
{ url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588 },
{ url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029 },
{ url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774 },
{ url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532 },
{ url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592 },
{ url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788 },
{ url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514 },
{ url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018 },
{ url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322 },
{ url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172 },
{ url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457 },
{ url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835 },
{ url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545 },
{ url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886 },
{ url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261 },
{ url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184 },
{ url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534 },
{ url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500 },
{ url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994 },
{ url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884 },
{ url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464 },
{ url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588 },
{ url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667 },
{ url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463 },
{ url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621 },
{ url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649 },
{ url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636 },
{ url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872 },
{ url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257 },
{ url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696 },
{ url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378 },
{ url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283 },
{ url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616 },
{ url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773 },
{ url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664 },
{ url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643 },
{ url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595 },
{ url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711 },
{ url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247 },
{ url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102 },
{ url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964 },
{ url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546 },
{ url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330 },
{ url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521 },
{ url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662 },
{ url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928 },
{ url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650 },
{ url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912 },
{ url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300 },
{ url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208 },
{ url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633 },
{ url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724 },
{ url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069 },
{ url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099 },
{ url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391 },
{ url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626 },
{ url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781 },
{ url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570 },
{ url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436 },
{ url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373 },
{ url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554 },
{ url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395 },
{ url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653 },
{ url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914 },
{ url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567 },
{ url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542 },
{ url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845 },
{ url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985 },
{ url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999 },
{ url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779 },
{ url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796 },
{ url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023 },
{ url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448 },
{ url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329 },
{ url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172 },
{ url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813 },
{ url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764 },
{ url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140 },
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036 },
]
[[package]]
name = "psycopg"
version = "3.3.4"
@@ -960,6 +1468,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -1053,6 +1573,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336 },
]
[[package]]
name = "s3transfer"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712 },
]
[[package]]
name = "shellingham"
version = "1.5.4"
@@ -1062,6 +1594,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
@@ -1184,6 +1725,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321 },
]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 },
]
[[package]]
name = "uvicorn"
version = "0.47.0"
@@ -1354,3 +1904,134 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531 },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598 },
]
[[package]]
name = "wrapt"
version = "1.17.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998 },
{ url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020 },
{ url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098 },
{ url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036 },
{ url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156 },
{ url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102 },
{ url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732 },
{ url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705 },
{ url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877 },
{ url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885 },
{ url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003 },
{ url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025 },
{ url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108 },
{ url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072 },
{ url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214 },
{ url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105 },
{ url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766 },
{ url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711 },
{ url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885 },
{ url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896 },
{ url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132 },
{ url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091 },
{ url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172 },
{ url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163 },
{ url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963 },
{ url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945 },
{ url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857 },
{ url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178 },
{ url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310 },
{ url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266 },
{ url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544 },
{ url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283 },
{ url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366 },
{ url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571 },
{ url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094 },
{ url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659 },
{ url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946 },
{ url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717 },
{ url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334 },
{ url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471 },
{ url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591 },
]
[[package]]
name = "yarl"
version = "1.24.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957 },
{ url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164 },
{ url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688 },
{ url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902 },
{ url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931 },
{ url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030 },
{ url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392 },
{ url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612 },
{ url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487 },
{ url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333 },
{ url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025 },
{ url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507 },
{ url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719 },
{ url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438 },
{ url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719 },
{ url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901 },
{ url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229 },
{ url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978 },
{ url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733 },
{ url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113 },
{ url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899 },
{ url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862 },
{ url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060 },
{ url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613 },
{ url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012 },
{ url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887 },
{ url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620 },
{ url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599 },
{ url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604 },
{ url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161 },
{ url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619 },
{ url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362 },
{ url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667 },
{ url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069 },
{ url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670 },
{ url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916 },
{ url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625 },
{ url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574 },
{ url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534 },
{ url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481 },
{ url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529 },
{ url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338 },
{ url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147 },
{ url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272 },
{ url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962 },
{ url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063 },
{ url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438 },
{ url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458 },
{ url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589 },
{ url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424 },
{ url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690 },
{ url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248 },
{ url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084 },
{ url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272 },
{ url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497 },
{ url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002 },
{ url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524 },
{ url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165 },
{ url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010 },
{ url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128 },
{ url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382 },
{ url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964 },
{ url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204 },
{ url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510 },
{ url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584 },
{ url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410 },
{ url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980 },
{ url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219 },
{ url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576 },
]

View File

@@ -3,7 +3,7 @@ id: ARTIFACT-STORE-WP-0001
type: workplan
title: "Foundation: Scaffold, Core Kernels, Local FS Backend"
repo: artifact-store
domain: stack
domain: infotech
status: done
owner: codex
topic_slug: stack

View File

@@ -3,14 +3,14 @@ id: ARTIFACT-STORE-WP-0002
type: workplan
title: "Ingestion API And Manifest Surface"
repo: artifact-store
domain: stack
status: planned
domain: infotech
status: done
owner: codex
topic_slug: stack
planning_priority: high
planning_order: 2
created: "2026-05-15"
updated: "2026-05-15"
updated: "2026-05-16"
state_hub_workstream_id: "cedbfe03-363c-43fd-a5cb-bef52b29af7e"
---
@@ -37,9 +37,9 @@ download files, and tail the event stream.
```task
id: ARTIFACT-STORE-WP-0002-T001
status: cancelled
status: done
priority: high
state_hub_task_id: "e3879111-4be9-4731-8aea-15abb874f960"
state_hub_task_id: "197e22ff-0003-433d-bfa0-2323152b85dc"
```
Acceptance:
@@ -58,7 +58,7 @@ Acceptance:
```task
id: ARTIFACT-STORE-WP-0002-T002
status: todo
status: done
priority: high
state_hub_task_id: "9c8c3853-2090-42be-9995-0b8ce4a76104"
```
@@ -78,7 +78,7 @@ Acceptance:
```task
id: ARTIFACT-STORE-WP-0002-T003
status: todo
status: done
priority: medium
state_hub_task_id: "710bbd2f-9bc1-4395-bbd1-2b22c1b7eb37"
```
@@ -99,7 +99,7 @@ Acceptance:
```task
id: ARTIFACT-STORE-WP-0002-T004
status: todo
status: done
priority: medium
state_hub_task_id: "d848bc41-edfa-48fc-bb2c-f2526f422c50"
```
@@ -117,7 +117,7 @@ Acceptance:
```task
id: ARTIFACT-STORE-WP-0002-T005
status: todo
status: done
priority: medium
state_hub_task_id: "27d33e90-6b31-4c1f-832b-870cd2c5fbe5"
```
@@ -134,7 +134,7 @@ Acceptance:
```task
id: ARTIFACT-STORE-WP-0002-T006
status: todo
status: done
priority: high
state_hub_task_id: "f422696f-a206-4030-be05-c342f94e9efd"
```

View File

@@ -3,8 +3,8 @@ id: ARTIFACT-STORE-WP-0003
type: workplan
title: "Retention Lifecycle: Defaults, Extensions, Holds, Deletion Eligibility"
repo: artifact-store
domain: stack
status: active
domain: infotech
status: done
owner: codex
topic_slug: stack
planning_priority: high
@@ -39,9 +39,9 @@ WP-0006).
```task
id: ARTIFACT-STORE-WP-0003-T001
status: cancelled
status: done
priority: high
state_hub_task_id: "2d6cbd83-c348-45ad-a223-7870a3412225"
state_hub_task_id: "25531837-d2ff-4252-b0d0-31283597737f"
```
Acceptance:
@@ -58,7 +58,7 @@ Acceptance:
```task
id: ARTIFACT-STORE-WP-0003-T002
status: in_progress
status: done
priority: high
state_hub_task_id: "66576e53-af4c-48dc-8dc3-cf8223a821c7"
```
@@ -77,7 +77,7 @@ Acceptance:
```task
id: ARTIFACT-STORE-WP-0003-T003
status: in_progress
status: done
priority: high
state_hub_task_id: "8164e448-0e90-41aa-a973-77f8f607a0b3"
```
@@ -97,7 +97,7 @@ Acceptance:
```task
id: ARTIFACT-STORE-WP-0003-T004
status: in_progress
status: done
priority: medium
state_hub_task_id: "fe13cd0d-aab7-4e0a-a7df-e6e535d4099b"
```
@@ -116,7 +116,7 @@ Acceptance:
```task
id: ARTIFACT-STORE-WP-0003-T005
status: in_progress
status: done
priority: medium
state_hub_task_id: "7dce0c92-76d6-4bfc-bbc5-8e18b96139d2"
```

View File

@@ -3,14 +3,14 @@ id: ARTIFACT-STORE-WP-0004
type: workplan
title: "S3-Compatible Backend (Ceph RGW Target)"
repo: artifact-store
domain: stack
status: planned
domain: infotech
status: done
owner: codex
topic_slug: stack
planning_priority: medium
planning_order: 4
created: "2026-05-15"
updated: "2026-05-15"
updated: "2026-05-17"
state_hub_workstream_id: "d0526cfc-e532-431f-970d-f3e548d27a80"
---
@@ -38,9 +38,9 @@ registry.
```task
id: ARTIFACT-STORE-WP-0004-T001
status: cancelled
status: done
priority: high
state_hub_task_id: "7b980a55-2364-48c3-98ac-081629a8d2b7"
state_hub_task_id: "1db0d548-cdac-4b07-962b-bcafa3aae30e"
```
Acceptance:
@@ -58,7 +58,7 @@ Acceptance:
```task
id: ARTIFACT-STORE-WP-0004-T002
status: todo
status: done
priority: high
state_hub_task_id: "14b50595-5820-4369-b037-b015fcbddcc4"
```
@@ -75,11 +75,15 @@ Acceptance:
- `head`, `delete`, `health` implemented.
- `delete` is idempotent (delete-of-missing returns success).
Decision: use `aioboto3` as the optional S3 client dependency. The
backend imports it lazily so local-only deployments do not need S3
dependencies installed.
## D4.3 - Backend Selection And Routing
```task
id: ARTIFACT-STORE-WP-0004-T003
status: todo
status: done
priority: medium
state_hub_task_id: "725dafd6-3337-4f81-b221-bb9f3a564d7e"
```
@@ -97,7 +101,7 @@ Acceptance:
```task
id: ARTIFACT-STORE-WP-0004-T004
status: todo
status: done
priority: high
state_hub_task_id: "4fd7b73b-7058-4edd-b5e3-edca396760d4"
```
@@ -111,11 +115,17 @@ Acceptance:
endpoint; results recorded in `docs/OPERATOR.md`.
- No CI dependency on a live Ceph or AWS account.
Closure note: the S3 backend implementation and local verification
for artifact-store are complete. MinIO-specific compatibility,
testcontainers/bootstrap, and community-fork assessment have been moved
to ARTIFACT-STORE-WP-0007 so this backend workstream can close without
hiding the remaining external-platform work.
## D4.5 - Verification Pass
```task
id: ARTIFACT-STORE-WP-0004-T005
status: todo
status: done
priority: medium
state_hub_task_id: "5a55546f-288f-4da0-a646-3d9319908279"
```

View File

@@ -3,14 +3,14 @@ id: ARTIFACT-STORE-WP-0005
type: workplan
title: "Guide-Board Pilot Ingestion"
repo: artifact-store
domain: stack
status: planned
domain: infotech
status: done
owner: codex
topic_slug: stack
planning_priority: high
planning_order: 5
created: "2026-05-15"
updated: "2026-05-15"
updated: "2026-05-17"
state_hub_workstream_id: "701c4d8c-5cf4-4a4a-ab60-1dcae53fe771"
---
@@ -41,9 +41,9 @@ bytes itself. This is the pilot success criterion in INTENT.md.
```task
id: ARTIFACT-STORE-WP-0005-T001
status: cancelled
status: done
priority: high
state_hub_task_id: "eb822821-353c-4cd2-95bf-acb2f084b7ea"
state_hub_task_id: "830f6822-1cfe-4955-a4e0-5b9a42fb5db1"
```
Acceptance:
@@ -61,7 +61,7 @@ Acceptance:
```task
id: ARTIFACT-STORE-WP-0005-T002
status: todo
status: done
priority: high
state_hub_task_id: "ff0ba2eb-b8d3-418a-8685-a54457cea2ed"
```
@@ -82,7 +82,7 @@ Acceptance:
```task
id: ARTIFACT-STORE-WP-0005-T003
status: todo
status: done
priority: high
state_hub_task_id: "5c367257-2d2a-4de9-9a06-663ba2c60d77"
```
@@ -106,7 +106,7 @@ Acceptance:
```task
id: ARTIFACT-STORE-WP-0005-T004
status: todo
status: done
priority: medium
state_hub_task_id: "b1ca7133-ad27-4091-93a0-a4e1b7450791"
```
@@ -124,7 +124,7 @@ Acceptance:
```task
id: ARTIFACT-STORE-WP-0005-T005
status: todo
status: done
priority: medium
state_hub_task_id: "bffa3573-4a1f-4c12-8c73-6d55bd8f6297"
```
@@ -139,6 +139,17 @@ Acceptance:
- Procedure runs end-to-end on a developer workstation under 5
minutes.
Closure note: the artifact-store ingest path was verified against an
existing non-fixture OpenCMIS guide-board run at
`/home/worsch/open-cmis-tck/.local/runs/opencmis-inmemory-pilot` using
an isolated SQLite DB and local storage root. It ingested 23 files,
replayed the event log through sequence 26, and verified 23 storage
locations with zero failures. A fresh guide-board/OpenCMIS producer run
from `~/guide-board` currently stops before artifact-store handoff with
`cmis-summary: report fragment not found: reports/cmis-summary.md`,
which belongs in guide-board/open-cmis-tck follow-up work rather than
holding this artifact-store integration workstream open.
## Success criteria
- A real guide-board CMIS run is ingested with one CLI invocation.

View File

@@ -0,0 +1,124 @@
---
id: ARTIFACT-STORE-WP-0006
type: workplan
title: "Garbage Collection And Reference Counting"
repo: artifact-store
domain: infotech
status: done
owner: codex
topic_slug: stack
planning_priority: high
planning_order: 6
created: "2026-05-16"
updated: "2026-05-16"
state_hub_workstream_id: "ccef72e9-a160-45c0-9952-c64be7c8cfa4"
---
# ARTIFACT-STORE-WP-0006: Garbage Collection And Reference Counting
## Purpose
Turn WP-0003 deletion eligibility into actual byte reclamation while
preserving auditability and global content-addressed deduplication. GC
must never delete bytes still referenced by a non-deleted storage
location.
## Constraints
- ADR-0001: content-addressed storage with global deduplication.
- ADR-0002: event log is the source of truth; materialised views are
replayable.
- ADR-0004: byte deletion goes through the data plane, not through
registry-specific backend code.
- WP-0003 deletion eligibility and retention holds are the policy gate.
## Prerequisites
- WP-0001 through WP-0003 done.
- WP-0004 backend SPI delete exists for all configured backends.
## D6.1 - Reference-Counted GC Planner
```task
id: ARTIFACT-STORE-WP-0006-T001
status: done
priority: high
state_hub_task_id: "438ed392-0f07-46cb-a6f5-88ce57b33fce"
```
Acceptance:
- GC selects only packages whose `retention_state.eligible_for_deletion`
is true and `active_hold_id` is null.
- It computes references by `(backend_id, content_address)` across all
non-deleted storage locations.
- It releases an eligible package's storage locations without deleting
bytes that are still referenced elsewhere.
## D6.2 - Byte Deletion And Audit Events
```task
id: ARTIFACT-STORE-WP-0006-T002
status: done
priority: high
state_hub_task_id: "8f512753-c402-480a-8517-990fccf09295"
```
Acceptance:
- When the eligible package set owns the final reference to a content
address, GC calls `DataPlane.delete_object`.
- GC emits replayable audit events for every released storage location,
including whether the physical object was deleted or retained due to
remaining references.
- Replay marks released storage locations as `deleted` and packages as
`garbage_collected` once every storage location for that package is
deleted.
## D6.3 - Operator Command And Docs
```task
id: ARTIFACT-STORE-WP-0006-T003
status: done
priority: medium
state_hub_task_id: "a36dce56-f87b-431a-b875-fc567593ddd3"
```
Acceptance:
- `artifactstore retention gc` runs one GC pass and prints a JSON
summary.
- `docs/OPERATOR.md` documents the safe sequence:
`artifactstore retention sweep` then `artifactstore retention gc`.
- The command is idempotent: running it again after a clean pass does
not delete or rewrite anything.
## D6.4 - Verification Tests
```task
id: ARTIFACT-STORE-WP-0006-T004
status: done
priority: high
state_hub_task_id: "b2a2d94f-bc5a-47ca-b540-920d94bff06e"
```
Acceptance:
- Tests cover unique-object deletion, shared-object reference retention,
hold-protected packages, idempotent reruns, replay, and CLI output.
- Full `pytest`, `ruff`, and `mypy` pass.
## Verification
- Focused tests: `tests/integration/test_garbage_collection.py` and
`tests/integration/test_cli_commands.py` passed.
- `ruff check .` passed.
- `mypy src tests` passed.
## Success criteria
- Expired, unheld packages can be reclaimed without losing bytes still
referenced by retained packages.
- The event log explains every logical release and physical delete.
- A replayed database reconstructs the same `deleted` storage-location
state and `garbage_collected` package status.

View File

@@ -0,0 +1,247 @@
---
id: ARTIFACT-STORE-WP-0007
type: workplan
title: "MinIO Compatibility, MaxIO Fork Assessment, And STS Credential Vending"
repo: artifact-store
domain: infotech
status: finished
owner: codex
topic_slug: stack
planning_priority: high
planning_order: 7
created: "2026-05-17"
updated: "2026-07-02"
state_hub_workstream_id: "2f34bb96-7206-4cb5-acdf-43880b57a9ec"
---
# ARTIFACT-STORE-WP-0007: MinIO Compatibility, MaxIO Fork Assessment, And STS Credential Vending
## Purpose
Create a dedicated workstream for the work that should not keep
artifact-store's S3 backend and guide-board pilot workstreams open:
MinIO-compatible test infrastructure, the "MaxIO" fork/community
opportunity, and whether NetKingdom already supports the Security Token
Service credential-vending pattern for object storage.
## Context
As of 2026-05-17, upstream `minio/minio` is archived/read-only on
GitHub and the README says the repository is no longer maintained.
The same README says Community Edition is now source-only, while the
source remains AGPLv3. The latest GitHub release visible there is
`RELEASE.2025-10-15T17-29-55Z`.
Relevant source references:
- https://github.com/minio/minio
- https://min.io/docs/minio/linux/developers/security-token-service.html
- https://min.io/docs/minio/linux/developers/security-token-service/AssumeRoleWithWebIdentity.html
- https://github.com/OpenMaxIO/openmaxio-object-browser
Initial local scan of `/home/worsch/net-kingdom` found credential
bootstrap, Vault/KeePassXC, OIDC, Keycloak/Authelia, and static S3/MinIO
backup references, but no explicit STS credential-vending implementation
or MinIO `AssumeRoleWithWebIdentity` path yet.
## Constraints
- Do not put MinIO fork or community governance assumptions into the
artifact-store S3 adapter.
- Treat AGPLv3, trademark/brand, release provenance, and security patch
obligations as first-class risks before any "MaxIO" fork decision.
- STS credential vending should issue short-lived credentials from
workload/user identity; long-lived root access keys should not become
the default integration pattern.
- NetKingdom owns identity/security architecture; artifact-store owns
whether its S3 backend can consume vendored temporary credentials.
## D7.1 - MinIO / Fork Landscape Assessment
```task
id: ARTIFACT-STORE-WP-0007-T001
status: done
priority: high
state_hub_task_id: "11d84b56-be7a-4013-8e21-36b7b656b69b"
```
Acceptance:
- Record a dated assessment of upstream MinIO status, latest usable
source tag, AGPL obligations, removed/enterprise-shifted features,
and available community forks.
- Compare at least: upstream source build, OpenMaxIO UI pieces, Pigsty
MinIO fork, Garage, RustFS, SeaweedFS, and Ceph RGW.
- Decide whether "MaxIO" should be a direct fork, a packaging/build
distribution, a compatibility profile, or not pursued.
Progress 2026-06-27:
- Added `docs/minio-compatibility-landscape-2026-06-27.md`, recording the dated
source/fork/object-store assessment and deciding that artifact-store should
pursue a compatibility profile rather than a direct MaxIO server fork.
- Verified current source references for upstream MinIO archive/tag posture,
MinIO AIStor STS/OIDC shape, OpenMaxIO UI scope, Garage, RustFS, SeaweedFS,
and Ceph RGW.
- D7.1 is done; follow-up implementation remains in D7.2-D7.5.
## D7.2 - MinIO Compatibility Harness
```task
id: ARTIFACT-STORE-WP-0007-T002
status: done
priority: high
state_hub_task_id: "c826f3ac-2ed7-4150-aa7c-e778ae71a72b"
```
Acceptance:
- Restore or define the dependency/bootstrap path for MinIO-compatible
integration tests (`uv`/Python deps, Docker/testcontainers or a
deterministic compose fixture).
- Run artifact-store S3 backend tests against the selected MinIO or fork
target.
- Document manual smoke commands and expected health/verify outputs.
Progress 2026-06-27:
- Added skipped-by-default live MinIO tests in
`tests/integration/test_storage_s3_minio.py`. The harness runs only when an
operator supplies `ARTIFACTSTORE_MINIO_ENDPOINT_URL`, bucket, access key, and
secret key through the local environment.
- Added `make test-minio` and documented the manual MinIO smoke in
`docs/OPERATOR.md`, including required permissions and the non-secret
application-level `ARTIFACTSTORE_S3_*_REF` mapping.
- Remaining D7.2 gate: run the harness against an approved MinIO-compatible
endpoint and capture the health/round-trip/multipart result.
Completed 2026-07-02:
- Added the deterministic local fixture `scripts/minio_local_smoke.sh` and
`make test-minio-local`: it starts a throwaway `minio/minio:latest`
container bound to `127.0.0.1:19000` with one-run generated credentials,
waits for `/minio/health/live` (HTTP 200), creates the smoke bucket via
`mc`, runs `make test-minio`, and tears the container down on exit.
- Live run passed against MinIO server (image digest `sha256:14cea493...`):
`test_live_minio_round_trip_with_range` and
`test_live_minio_multipart_upload` — 2 passed. Health, round-trip with
range reads, and multipart upload are all verified against a real MinIO
endpoint; no credentials persisted anywhere.
- This closes D7.2's bootstrap-path, live-run, and documentation acceptance.
Runs against a production/approved shared endpoint remain possible with the
same `make test-minio` env contract whenever an operator supplies one.
## D7.3 - STS Credential Vending Assessment For NetKingdom
```task
id: ARTIFACT-STORE-WP-0007-T003
status: done
priority: high
state_hub_task_id: "d3d5c4c1-d3b2-4163-b99d-1b08f90566d1"
```
Acceptance:
- Inventory NetKingdom's current object-storage credential path,
including backup jobs and any S3/MinIO secrets.
- Determine whether Keycloak/Authelia/local-identity can act as the OIDC
identity provider for MinIO-compatible `AssumeRoleWithWebIdentity`.
- Produce a target architecture for credential vending: issuer,
token audience, role/policy mapping, expiration, revocation, audit,
and break-glass behavior.
Completed 2026-07-02: added `docs/sts-credential-vending-assessment.md`,
specializing the NetKingdom baseline (`net-kingdom/docs/object-storage-sts-
credential-vending.md`, NK-WP-0007) for artifact-store. Inventory found no
production-live object-storage credentials yet (artifact-store static-ref
bridge, CNPG backup lane parked pre-provisioning), confirmed key-cape/Keycloak
as viable MinIO `AssumeRoleWithWebIdentity` issuers (Authelia rejected —
no IAM Profile claims; local-identity sandbox-only), and bound the target
architecture: vending-service audience, flex-auth decision vocabulary, 1560
min leases with refresh jitter, audit event shape, and break-glass rules.
Key code finding for D7.4: `S3BackendConfig` lacks `session_token` and the
`aioboto3.Session` omits `aws_session_token`, so STS credentials cannot be
consumed until that lands.
## D7.4 - Artifact-Store Temporary Credential Support
```task
id: ARTIFACT-STORE-WP-0007-T004
status: done
priority: medium
state_hub_task_id: "9b80057a-d86e-4f14-9d14-928ee29f970d"
```
Acceptance:
- Decide whether artifact-store's S3 backend needs dynamic credential
refresh for STS-vended credentials or whether refresh belongs in a
sidecar/secret controller.
- If needed, design the minimal configuration shape for short-lived
credentials without storing them in request bodies or event payloads.
- Verify that `artifactstore storage verify --backend s3` can run with
temporary credentials.
Completed 2026-07-02:
- Decision: in-process refresh uses per-client re-resolution of `file:` refs
(sidecar/controller rewrites the mounted files atomically); no long-lived
credential state is cached, and values never enter request bodies, events,
or config dumps.
- Config shape: `S3BackendConfig.session_token` +
`ARTIFACTSTORE_S3_SESSION_TOKEN_REF` (env:/file: ref like the existing key
refs); `S3Backend` accepts an optional `credentials_provider` returning
(access, secret, token) per client.
- Live verification against a local MinIO: the smoke's new STS leg mints
temporary credentials via `AssumeRole` for a scoped non-root user and
passes round-trip/range/multipart with the session token
(`make test-minio-local`), and the CLI ran `migrate`/`health`/`storage
verify --backend s3` with STS credentials delivered through `file:` refs —
backend health `ok` proves a live authenticated `head_bucket`.
- `make test` 112 passed / 2 skipped; targeted Ruff clean. Unit tests cover
session-token pass-through and per-client provider re-resolution.
## D7.5 - Follow-Up Workstream Routing
```task
id: ARTIFACT-STORE-WP-0007-T005
status: done
priority: medium
state_hub_task_id: "614f7918-6fef-4460-b3fc-f9ff3c156422"
```
Acceptance:
- Create or link NetKingdom follow-up work for STS credential vending if
the implementation belongs outside artifact-store.
- Create or link producer-side guide-board/open-cmis-tck work for the
missing `reports/cmis-summary.md` fragment.
- Close this workstream with a decision: adopt existing fork, build
MaxIO, use another S3-compatible store, or defer.
Completed 2026-07-02:
- NetKingdom STS follow-up routed via State Hub message
`b57b3403-e7f0-411d-964f-f83723163bd5` to `net-kingdom`: vending service,
flex-auth vocabulary, and MinIO OIDC issuer config are NetKingdom/platform
owned per the NK-WP-0007 baseline ownership table; artifact-store consumer
support is complete (D7.4).
- Producer-side `reports/cmis-summary.md` fragment gap routed via State Hub
message `e5ba736d-17e8-4896-8d64-e7f775bba0ce` to `open-cmis-tck` (the
GUIDE-BOARD-WP-0007 fragment mechanism is complete; the extension must
provide the fragment it declares).
- Closing decision: **use MinIO-compatible stores through the compatibility
profile (D7.1) with STS temporary credentials (D7.3/D7.4); the MaxIO fork
is deferred indefinitely** — revisit only if a compatibility break or a
governance/licence event makes the profile untenable.
## Success criteria
- Artifact-store no longer treats MinIO as an incidental CI detail; it
has a clear compatibility and governance strategy.
- NetKingdom has a concrete answer on STS credential vending for object
storage.
- Any MaxIO fork work starts only after legal, security, governance,
and community-support duties are explicit.