generated from coulomb/repo-seed
Compare commits
22 Commits
66a8ce1bc9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f454da406 | |||
| 5858a7309b | |||
| cdcd2390c4 | |||
| 9561e9620f | |||
| fca7b7c0e8 | |||
| e689929da0 | |||
| 0fd2f29e87 | |||
| bcda9ad1d7 | |||
| 93ca9ea3b9 | |||
| 4df29ba9f0 | |||
| cbbbe93594 | |||
| a60d24f814 | |||
| 1dae855700 | |||
| 91bb08c8e5 | |||
| 1f379ba321 | |||
| 864f7f203c | |||
| b7ceaf7682 | |||
| c33baa3635 | |||
| 2173f702c1 | |||
| b9ae4d83ba | |||
| 788e585694 | |||
| 5911a7bca0 |
20
.claude/rules/agents.md
Normal file
20
.claude/rules/agents.md
Normal 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.
|
||||
8
.claude/rules/architecture.md
Normal file
8
.claude/rules/architecture.md
Normal 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
|
||||
50
.claude/rules/credential-routing.md
Normal file
50
.claude/rules/credential-routing.md
Normal 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`
|
||||
38
.claude/rules/first-session.md
Normal file
38
.claude/rules/first-session.md
Normal 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 1–3 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 -->
|
||||
8
.claude/rules/repo-boundary.md
Normal file
8
.claude/rules/repo-boundary.md
Normal 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/
|
||||
-->
|
||||
5
.claude/rules/repo-identity.md
Normal file
5
.claude/rules/repo-identity.md
Normal 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
|
||||
85
.claude/rules/session-protocol.md
Normal file
85
.claude/rules/session-protocol.md
Normal 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.
|
||||
19
.claude/rules/stack-and-commands.md
Normal file
19
.claude/rules/stack-and-commands.md
Normal 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)
|
||||
```
|
||||
40
.claude/rules/workplan-convention.md
Normal file
40
.claude/rules/workplan-convention.md
Normal 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 -->
|
||||
@@ -1,46 +1,25 @@
|
||||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||
# Custodian Brief — artifact-store
|
||||
|
||||
**Domain:** stack
|
||||
**Last synced:** 2026-05-16 13:15 UTC
|
||||
**Domain:** infotech
|
||||
**Last synced:** 2026-06-27 21:32 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`
|
||||
### MinIO Compatibility, MaxIO Fork Assessment, And STS Credential Vending
|
||||
Progress: 1/5 done | workstream_id: `2f34bb96-7206-4cb5-acdf-43880b57a9ec`
|
||||
|
||||
**Open tasks:**
|
||||
- · D3.1 - Default Retention Application `25531837`
|
||||
- · 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`
|
||||
|
||||
### 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`
|
||||
- ► D7.2 - MinIO Compatibility Harness `c826f3ac`
|
||||
- · D7.3 - STS Credential Vending Assessment For NetKingdom `d3d5c4c1`
|
||||
- · D7.4 - Artifact-Store Temporary Credential Support `9b80057a`
|
||||
- · D7.5 - Follow-Up Workstream Routing `614f7918`
|
||||
|
||||
---
|
||||
## 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.
|
||||
|
||||
47
.env.example
47
.env.example
@@ -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
16
.repo-classification.yaml
Normal 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
134
AGENTS.md
@@ -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 (A1–A9).
|
||||
- `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
12
CLAUDE.md
Normal 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
|
||||
6
Makefile
6
Makefile
@@ -1,10 +1,11 @@
|
||||
.PHONY: help install dev test lint format type migrate migrate-fresh clean
|
||||
.PHONY: help install dev test test-minio 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 " lint ruff check + ruff format --check"
|
||||
@echo " format ruff format (write changes)"
|
||||
@echo " type mypy --strict over src and tests"
|
||||
@@ -21,6 +22,9 @@ dev:
|
||||
test:
|
||||
uv run pytest
|
||||
|
||||
test-minio:
|
||||
uv run --all-extras pytest tests/integration/test_storage_s3_minio.py -m integration
|
||||
|
||||
lint:
|
||||
uv run ruff check .
|
||||
uv run ruff format --check .
|
||||
|
||||
187
docs/OPERATOR.md
187
docs/OPERATOR.md
@@ -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,71 @@ 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_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 +161,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 +250,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-0002–WP-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 +323,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 +367,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
89
docs/minio-compatibility-landscape-2026-06-27.md
Normal file
89
docs/minio-compatibility-landscape-2026-06-27.md
Normal 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
162
docs/pilots/guide-board.md
Normal 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.
|
||||
@@ -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
12
registry/README.md
Normal 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`.
|
||||
0
registry/capabilities/.gitkeep
Normal file
0
registry/capabilities/.gitkeep
Normal file
4
registry/indexes/capabilities.yaml
Normal file
4
registry/indexes/capabilities.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
version: 1
|
||||
updated: '2026-06-16'
|
||||
domain: helix_forge
|
||||
capabilities: []
|
||||
42
schemas/guide-board.run.v1.json
Normal file
42
schemas/guide-board.run.v1.json
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
133
scripts/link-guide-board-package.py
Normal file
133
scripts/link-guide-board-package.py
Normal 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()
|
||||
44
scripts/register-guide-board-schema.py
Normal file
44
scripts/register-guide-board-schema.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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,65 @@ 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:
|
||||
access_key = (
|
||||
resolve_secret_ref(settings.s3_access_key_ref)
|
||||
if settings.s3_access_key_ref
|
||||
else None
|
||||
)
|
||||
secret_key = (
|
||||
resolve_secret_ref(settings.s3_secret_key_ref)
|
||||
if settings.s3_secret_key_ref
|
||||
else None
|
||||
)
|
||||
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,
|
||||
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,
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,86 @@ 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_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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
1
src/artifactstore/pilots/__init__.py
Normal file
1
src/artifactstore/pilots/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Pilot producer integrations."""
|
||||
308
src/artifactstore/pilots/guide_board.py
Normal file
308
src/artifactstore/pilots/guide_board.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
261
src/artifactstore/storage/backends/s3.py
Normal file
261
src/artifactstore/storage/backends/s3.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""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
|
||||
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]]
|
||||
|
||||
|
||||
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,
|
||||
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._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
|
||||
session = aioboto3.Session(
|
||||
aws_access_key_id=self._config.access_key_id,
|
||||
aws_secret_access_key=self._config.secret_access_key,
|
||||
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
|
||||
8
tests/fixtures/guide-board/logs/log-review-summary.json
vendored
Normal file
8
tests/fixtures/guide-board/logs/log-review-summary.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"reviewed_logs": [
|
||||
"raw/session/transcript.txt"
|
||||
],
|
||||
"warnings": [
|
||||
"Repository returned one optional capability warning."
|
||||
]
|
||||
}
|
||||
6
tests/fixtures/guide-board/raw/session/browser-response.json
vendored
Normal file
6
tests/fixtures/guide-board/raw/session/browser-response.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"repositoryId": "fixture-repo",
|
||||
"capabilities": {
|
||||
"capabilityQuery": "metadataonly"
|
||||
}
|
||||
}
|
||||
3
tests/fixtures/guide-board/raw/session/transcript.txt
vendored
Normal file
3
tests/fixtures/guide-board/raw/session/transcript.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
GET /cmis/browser
|
||||
200 OK
|
||||
Repository info collected for fixture.
|
||||
12
tests/fixtures/guide-board/reports/assessment-package.json
vendored
Normal file
12
tests/fixtures/guide-board/reports/assessment-package.json
vendored
Normal 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" }
|
||||
]
|
||||
}
|
||||
3
tests/fixtures/guide-board/reports/report.md
vendored
Normal file
3
tests/fixtures/guide-board/reports/report.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Guide-board CMIS Assessment
|
||||
|
||||
Fixture run `gb-fixture-001` completed with one warning and no failed checks.
|
||||
17
tests/fixtures/guide-board/retention-summary.json
vendored
Normal file
17
tests/fixtures/guide-board/retention-summary.json
vendored
Normal 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
10
tests/fixtures/guide-board/run.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
7
tests/fixtures/guide-board/scorecards/cmis-scorecard.json
vendored
Normal file
7
tests/fixtures/guide-board/scorecards/cmis-scorecard.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"scorecard": "cmis-browser-binding",
|
||||
"checks": 18,
|
||||
"passed": 17,
|
||||
"warnings": 1,
|
||||
"failed": 0
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
206
tests/integration/test_garbage_collection.py
Normal file
206
tests/integration/test_garbage_collection.py
Normal 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"
|
||||
110
tests/integration/test_guide_board_pilot.py
Normal file
110
tests/integration/test_guide_board_pilot.py
Normal 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
|
||||
240
tests/integration/test_http_api.py
Normal file
240
tests/integration/test_http_api.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
250
tests/integration/test_retention_lifecycle.py
Normal file
250
tests/integration/test_retention_lifecycle.py
Normal 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"
|
||||
)
|
||||
103
tests/integration/test_storage_s3_minio.py
Normal file
103
tests/integration/test_storage_s3_minio.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""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"],
|
||||
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)
|
||||
39
tests/unit/test_config_storage.py
Normal file
39
tests/unit/test_config_storage.py
Normal 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")
|
||||
@@ -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:
|
||||
|
||||
196
tests/unit/test_storage_s3.py
Normal file
196
tests/unit/test_storage_s3.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""S3-compatible backend tests (ARTIFACT-STORE-WP-0004)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
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"
|
||||
681
uv.lock
generated
681
uv.lock
generated
@@ -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 },
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
@@ -3,14 +3,14 @@ id: ARTIFACT-STORE-WP-0003
|
||||
type: workplan
|
||||
title: "Retention Lifecycle: Defaults, Extensions, Holds, Deletion Eligibility"
|
||||
repo: artifact-store
|
||||
domain: stack
|
||||
status: planned
|
||||
domain: infotech
|
||||
status: done
|
||||
owner: codex
|
||||
topic_slug: stack
|
||||
planning_priority: high
|
||||
planning_order: 3
|
||||
created: "2026-05-15"
|
||||
updated: "2026-05-15"
|
||||
updated: "2026-05-16"
|
||||
state_hub_workstream_id: "84930f4c-3bcf-415e-a94c-bfa854a15871"
|
||||
---
|
||||
|
||||
@@ -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: todo
|
||||
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: todo
|
||||
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: todo
|
||||
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: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "7dce0c92-76d6-4bfc-bbc5-8e18b96139d2"
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
124
workplans/ARTIFACT-STORE-WP-0006-garbage-collection.md
Normal file
124
workplans/ARTIFACT-STORE-WP-0006-garbage-collection.md
Normal 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.
|
||||
183
workplans/ARTIFACT-STORE-WP-0007-minio-maxio-sts-vending.md
Normal file
183
workplans/ARTIFACT-STORE-WP-0007-minio-maxio-sts-vending.md
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
id: ARTIFACT-STORE-WP-0007
|
||||
type: workplan
|
||||
title: "MinIO Compatibility, MaxIO Fork Assessment, And STS Credential Vending"
|
||||
repo: artifact-store
|
||||
domain: infotech
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: stack
|
||||
planning_priority: high
|
||||
planning_order: 7
|
||||
created: "2026-05-17"
|
||||
updated: "2026-06-27"
|
||||
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: progress
|
||||
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.
|
||||
|
||||
## D7.3 - STS Credential Vending Assessment For NetKingdom
|
||||
|
||||
```task
|
||||
id: ARTIFACT-STORE-WP-0007-T003
|
||||
status: todo
|
||||
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.
|
||||
|
||||
## D7.4 - Artifact-Store Temporary Credential Support
|
||||
|
||||
```task
|
||||
id: ARTIFACT-STORE-WP-0007-T004
|
||||
status: todo
|
||||
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.
|
||||
|
||||
## D7.5 - Follow-Up Workstream Routing
|
||||
|
||||
```task
|
||||
id: ARTIFACT-STORE-WP-0007-T005
|
||||
status: todo
|
||||
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.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user